From 917f577fe57b74ed6a094d2c59112e68f44ebfca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Akif=20=C3=87=C3=96RD=C3=9CK?= Date: Fri, 21 Aug 2026 13:43:17 +0200 Subject: [PATCH 01/78] Mod-2 cut separator for zero-half cuts (#1726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add general mod-2 zero-half separation. The separator finds combinations of constraint rows whose odd coefficients cancel modulo 2 while their aggregated right-hand side remains odd. Halving and rounding this aggregate yields a valid cut. - Use sparse GF(2) elimination to find these row dependencies, then transform, lift, score, and add violated candidates to the cut pool. - Propagate B&B timeouts to active node solves and improve work unit checks for some cuts. - Update PaPILO pin for the coefficient-capacity guard fix. On H100, two runs each: Main vs mod-2 **Feasible** | 224.0 | 226.5 | +2.5 **Average error gap** | 12.285 | 11.655 | -0.630 **Optimal instances** | 76.5 | 77.5 | +1.0 **Root gap closed average** | 29.7518% | 31.0948% | +1.3429% **Root gap closed shifted geomean** (+1) | 10.4875% | 11.1626% | +0.6750% MIP gap unchanged, due to some cut passes taking longer. Authors: - Akif ÇÖRDÜK (https://github.com/akifcorduk) Approvers: - Alice Boucher (https://github.com/aliceb-nv) - Nicolas L. Guidotti (https://github.com/nguidotti) - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv) URL: https://github.com/NVIDIA/cuopt/pull/1726 --- cpp/CMakeLists.txt | 2 +- cpp/src/branch_and_bound/branch_and_bound.cpp | 25 +- cpp/src/cuts/CMakeLists.txt | 1 + cpp/src/cuts/cuts.cpp | 105 ++- cpp/src/cuts/cuts.hpp | 59 +- cpp/src/cuts/zero_half_mod2.cpp | 770 ++++++++++++++++++ cpp/tests/mip/cuts_test.cu | 75 +- skills/cuopt-developer/SKILL.md | 2 +- .../cuopt-developer/references/conventions.md | 17 +- 9 files changed, 1011 insertions(+), 45 deletions(-) create mode 100644 cpp/src/cuts/zero_half_mod2.cpp diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 1bef3a15cd..b375cc4c56 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -277,7 +277,7 @@ FetchContent_Declare( # This is the reason we are using the development branch # from Oct 12, 2025. Once these changes are merged into the main branch, #we can switch to the main branch. - GIT_TAG "32b3a87dbf4955d5a2803be74145c389ea31434d" + GIT_TAG "55d5edece584885061639ecf3a6eb8a4629be9f2" GIT_PROGRESS TRUE EXCLUDE_FROM_ALL SYSTEM diff --git a/cpp/src/branch_and_bound/branch_and_bound.cpp b/cpp/src/branch_and_bound/branch_and_bound.cpp index 4dc6bc67a8..fd7987ae1c 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.cpp +++ b/cpp/src/branch_and_bound/branch_and_bound.cpp @@ -1565,8 +1565,9 @@ dual_status_t branch_and_bound_t::solve_node_lp( } else { lp_settings.cut_off = cutoff + settings_.dual_tol; } - lp_settings.inside_mip = 2; - lp_settings.time_limit = settings_.time_limit - toc(exploration_stats_.start_time); + lp_settings.inside_mip = 2; + lp_settings.time_limit = settings_.time_limit - toc(exploration_stats_.start_time); + if (lp_settings.time_limit <= 0.0) { return dual_status_t::TIME_LIMIT; } lp_settings.scale_columns = false; lp_settings.iteration_limit = iter_limit; @@ -1823,6 +1824,10 @@ void branch_and_bound_t::plunge_with(bfs_worker_t* worker, abs_gap = compute_user_abs_gap(original_lp_, upper_bound, lower_bound); } + if (solver_status_ == mip_status_t::TIME_LIMIT || solver_status_ == mip_status_t::OPTIMAL) { + node_concurrent_halt_ = 1; + } + // If the solver exits early without consuming the local stack, or converged according to // the gap rules while nodes are still pending, put those nodes back into the global queue // before returning. @@ -1840,6 +1845,9 @@ void branch_and_bound_t::plunge_with(bfs_worker_t* worker, template void branch_and_bound_t::launch_bfs_worker(bfs_worker_t* worker) { + // The status may change after the caller checks its search-loop condition. + if (solver_status_ != mip_status_t::UNSET) { return; } + bfs_worker_t* idle_worker = bfs_worker_pool_.pop_idle_worker(); if (!idle_worker) return; @@ -1976,8 +1984,7 @@ void branch_and_bound_t::best_first_search_with(bfs_worker_t rel_gap = user_relative_gap(user_obj, user_lower); if (abs_gap <= settings_.absolute_mip_gap_tol || rel_gap <= settings_.relative_mip_gap_tol) { - node_concurrent_halt_ = 1; - solver_status_ = mip_status_t::OPTIMAL; + solver_status_ = mip_status_t::OPTIMAL; break; } @@ -1987,6 +1994,10 @@ void branch_and_bound_t::best_first_search_with(bfs_worker_t } } + if (solver_status_ == mip_status_t::TIME_LIMIT || solver_status_ == mip_status_t::OPTIMAL) { + node_concurrent_halt_ = 1; + } + // If the worker has still nodes in the queue (this can happen if it was stopped due to // time limit, small gap or other reason), then do not add back to the pool to avoid // constantly trying to start it again @@ -2044,7 +2055,8 @@ void branch_and_bound_t::dive_with(diving_worker_t* worker, } if (toc(exploration_stats_.start_time) > settings_.time_limit) { - solver_status_ = mip_status_t::TIME_LIMIT; + node_concurrent_halt_ = 1; + solver_status_ = mip_status_t::TIME_LIMIT; break; } if (dive_stats.nodes_explored >= diving_node_limit) { break; } @@ -2063,7 +2075,8 @@ void branch_and_bound_t::dive_with(diving_worker_t* worker, ++dive_stats.nodes_explored; if (lp_status == dual_status_t::TIME_LIMIT) { - solver_status_ = mip_status_t::TIME_LIMIT; + node_concurrent_halt_ = 1; + solver_status_ = mip_status_t::TIME_LIMIT; break; } if (lp_status == dual_status_t::CONCURRENT_LIMIT) { break; } diff --git a/cpp/src/cuts/CMakeLists.txt b/cpp/src/cuts/CMakeLists.txt index 813ac88a59..2d4412d00a 100644 --- a/cpp/src/cuts/CMakeLists.txt +++ b/cpp/src/cuts/CMakeLists.txt @@ -6,6 +6,7 @@ set(CUTS_SRC_FILES ${CMAKE_CURRENT_SOURCE_DIR}/cuts.cpp ${CMAKE_CURRENT_SOURCE_DIR}/objective_step.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/zero_half_mod2.cpp ) set(CUOPT_SRC_FILES ${CUOPT_SRC_FILES} diff --git a/cpp/src/cuts/cuts.cpp b/cpp/src/cuts/cuts.cpp index be45ffeecd..7acd7dee0a 100644 --- a/cpp/src/cuts/cuts.cpp +++ b/cpp/src/cuts/cuts.cpp @@ -2324,7 +2324,8 @@ i_t knapsack_generation_t::generate_knapsack_cut( const std::vector& var_types, const std::vector& xstar, i_t knapsack_row, - inequality_t& cut) + inequality_t& cut, + f_t start_time) { const bool verbose = false; // Get the row associated with the knapsack constraint @@ -2499,7 +2500,8 @@ i_t knapsack_generation_t::generate_knapsack_cut( // Lift the cut inequality_t lifted_cut(lp.num_cols); - lift_knapsack_cut(knapsack_inequality, minimal_cover_cut, c1_partition, c2_partition, lifted_cut); + lift_knapsack_cut( + knapsack_inequality, minimal_cover_cut, c1_partition, c2_partition, lifted_cut, start_time); lifted_cut.negate(); // The cut is now in the form: @@ -2679,7 +2681,8 @@ void knapsack_generation_t::lift_knapsack_cut( const inequality_t& base_cut, const std::vector& c1_partition, const std::vector& c2_partition, - inequality_t& lifted_cut) + inequality_t& lifted_cut, + f_t start_time) { // The base cut is in the form: sum_{j in cover} x_j <= |cover| - 1 @@ -2795,14 +2798,15 @@ void knapsack_generation_t::lift_knapsack_cut( best_score_last_permutation(remaining_coefficients, permutation); while (permutation.size() > 0) { + if (toc(start_time) >= settings_.time_limit) { break; } const i_t h = permutation.back(); const i_t k = remaining_variables[h]; const f_t a_k = remaining_coefficients[h]; f_t capacity = knapsack_inequality.rhs - a_k; - f_t objective = - exact_knapsack_problem_integer_values_fraction_values(values, weights, capacity, solution); + f_t objective = exact_knapsack_problem_integer_values_fraction_values( + values, weights, capacity, solution, start_time); if (std::isnan(objective)) { settings_.log.debug("lifting knapsack problem failed\n"); break; @@ -3021,8 +3025,10 @@ f_t knapsack_generation_t::exact_knapsack_problem_integer_values_fract const std::vector& values, const std::vector& weights, f_t rhs, - std::vector& solution) + std::vector& solution, + f_t start_time) { + if (toc(start_time) >= settings_.time_limit) { return std::numeric_limits::quiet_NaN(); } // Solve the knapsack problem // maximize sum_{j=0}^n values[j] * solution[j] // subject to sum_{j=0}^n weights[j] * solution[j] <= rhs @@ -3050,6 +3056,7 @@ f_t knapsack_generation_t::exact_knapsack_problem_integer_values_fract // 4. Dynamic programming for (i_t j = 1; j <= n; ++j) { + if (toc(start_time) >= settings_.time_limit) { return std::numeric_limits::quiet_NaN(); } for (i_t v = 0; v <= sum_value; ++v) { // Do not take item i-1 dp(j, v) = dp(j - 1, v); @@ -3095,19 +3102,27 @@ void cut_generation_t::generate_implied_bound_cuts( { if (probing_implied_bound_.zero_offsets.empty()) { return; } - const f_t tol = 1e-4; - i_t num_cuts = 0; - const i_t pib_cols = static_cast(probing_implied_bound_.zero_offsets.size()) - 1; - const i_t n_cols = std::min(lp.num_cols, pib_cols); + const f_t tol = 1e-4; + i_t num_cuts = 0; + const i_t pib_cols = probing_implied_bound_.zero_offsets.size() - 1; + const i_t n_cols = std::min(lp.num_cols, pib_cols); + f_t work_estimate = 0.0; + const f_t max_work_estimate = 1e8; + constexpr f_t implication_work = 16.0; + constexpr f_t generated_cut_work = 16.0; for (i_t j = 0; j < n_cols; j++) { + if (work_estimate > max_work_estimate || toc(start_time) >= settings.time_limit) { return; } if (var_types[j] == variable_type_t::CONTINUOUS) { continue; } const f_t xstar_j = xstar[j]; // x_j = 0 implications const i_t zero_begin = probing_implied_bound_.zero_offsets[j]; const i_t zero_end = probing_implied_bound_.zero_offsets[j + 1]; + const i_t one_begin = probing_implied_bound_.one_offsets[j]; + const i_t one_end = probing_implied_bound_.one_offsets[j + 1]; for (i_t p = zero_begin; p < zero_end; p++) { + work_estimate += implication_work; const i_t i = probing_implied_bound_.zero_variables[p]; if (i == j) { continue; } const f_t l_i = lp.lower[i]; @@ -3127,6 +3142,7 @@ void cut_generation_t::generate_implied_bound_cuts( cut.push_back(j, coeff_j); cut.rhs = -b_ub; cut_pool_.add_cut(cut_type_t::IMPLIED_BOUND, cut); + work_estimate += generated_cut_work; num_cuts++; } } @@ -3145,15 +3161,16 @@ void cut_generation_t::generate_implied_bound_cuts( cut.push_back(j, coeff_j); cut.rhs = b_lb; cut_pool_.add_cut(cut_type_t::IMPLIED_BOUND, cut); + work_estimate += generated_cut_work; num_cuts++; } } } + if (work_estimate > max_work_estimate || toc(start_time) >= settings.time_limit) { return; } // x_j = 1 implications - const i_t one_begin = probing_implied_bound_.one_offsets[j]; - const i_t one_end = probing_implied_bound_.one_offsets[j + 1]; for (i_t p = one_begin; p < one_end; p++) { + work_estimate += implication_work; const i_t i = probing_implied_bound_.one_variables[p]; if (i == j) { continue; } const f_t l_i = lp.lower[i]; @@ -3173,6 +3190,7 @@ void cut_generation_t::generate_implied_bound_cuts( cut.push_back(j, coeff_j); cut.rhs = -u_i; cut_pool_.add_cut(cut_type_t::IMPLIED_BOUND, cut); + work_estimate += generated_cut_work; num_cuts++; } } @@ -3190,10 +3208,12 @@ void cut_generation_t::generate_implied_bound_cuts( cut.push_back(j, coeff_j); cut.rhs = rhs_val; cut_pool_.add_cut(cut_type_t::IMPLIED_BOUND, cut); + work_estimate += generated_cut_work; num_cuts++; } } } + if (work_estimate > max_work_estimate || toc(start_time) >= settings.time_limit) { return; } } if (num_cuts > 0) { @@ -3604,7 +3624,8 @@ bool cut_generation_t::generate_cuts(const lp_problem_t& lp, if (toc(start_time) >= settings.time_limit) { return true; } ZERO_HALF_DEBUG("generate_cuts: about to call generate_zero_half_cuts"); f_t cut_start_time = tic(); - bool feasible = generate_zero_half_cuts(lp, settings, var_types, xstar, zstar, start_time); + bool feasible = generate_zero_half_cuts( + lp, settings, Arow, new_slacks, var_types, xstar, zstar, variable_bounds, start_time); ZERO_HALF_DEBUG("generate_cuts: returned from generate_zero_half_cuts feasible=%d", static_cast(feasible)); if (!feasible) { @@ -3637,7 +3658,7 @@ void cut_generation_t::generate_knapsack_cuts( if (toc(start_time) >= settings.time_limit) { return; } inequality_t cut(lp.num_cols); i_t knapsack_status = knapsack_generation_.generate_knapsack_cut( - lp, settings, Arow, new_slacks, var_types, xstar, knapsack_row, cut); + lp, settings, Arow, new_slacks, var_types, xstar, knapsack_row, cut, start_time); if (knapsack_status == 0) { cut_pool_.add_cut(cut_type_t::KNAPSACK, cut); } } } @@ -3855,9 +3876,12 @@ template bool cut_generation_t::generate_zero_half_cuts( const lp_problem_t& lp, const simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const std::vector& new_slacks, const std::vector& var_types, const std::vector& xstar, const std::vector& reduced_costs, + variable_bounds_t& variable_bounds, f_t start_time) { if (settings.zero_half_cuts == 0) { return true; } @@ -3882,12 +3906,25 @@ bool cut_generation_t::generate_zero_half_cuts( static_cast(sub_cg_.ready), sub_cg_.vertices.size()); + f_t mod2_work_estimate = 0.0; + const bool mod2_completed = generate_mod2_zero_half_cuts(cut_pool_, + lp, + settings, + Arow, + new_slacks, + var_types, + xstar, + variable_bounds, + start_time, + mod2_work_estimate); + if (!mod2_completed) { return true; } + // The fractional conflict-graph subgraph is built once per cut pass in - // prepare_fractional_sub_conflict_graph() (called from generate_cuts) and shared with - // the clique-cut separator. Skip if the build was unable to produce a - // useable sub-CG (clique table missing/empty, work/time budget hit, etc.). + // prepare_fractional_sub_conflict_graph() and remains a complementary + // odd-cycle / odd-wheel separator. If no conflict graph is available, the + // general row-parity cuts above are still retained. if (!sub_cg_.ready) { - ZERO_HALF_DEBUG("sub_cg_ not ready, skipping"); + ZERO_HALF_DEBUG("sub_cg_ not ready, skipping odd-cycle path"); return true; } if (sub_cg_.empty_subgraph()) { @@ -3905,8 +3942,8 @@ bool cut_generation_t::generate_zero_half_cuts( cuopt_assert(user_problem_.var_types.size() == static_cast(num_vars), "Zero-half user problem var_types size mismatch"); - const f_t min_violation = std::max(settings.primal_tol, static_cast(1e-6)); - const f_t bound_tol = settings.primal_tol; + constexpr f_t min_violation = (f_t)1e-6; + const f_t bound_tol = settings.primal_tol; // shortest path of length >= 0.5 - min_violation cannot yield a violated cut const f_t cutoff = static_cast(0.5) - min_violation; f_t work_estimate = 0.0; @@ -4070,21 +4107,22 @@ void cut_generation_t::generate_mir_cuts( // at the beginning of each iteration of the for loop below std::vector aggregated_rows; std::vector aggregated_mark(lp.num_rows, 0); + const i_t max_cuts = std::min(lp.num_rows, 100000); // Transform the relaxation solution std::vector transformed_xstar; complemented_mir.bound_substitution(lp, variable_bounds, var_types, xstar, transformed_xstar); - const i_t max_cuts = std::min(lp.num_rows, 100000); f_t work_estimate = 0.0; - i_t num_cuts = 0; - while (num_cuts < max_cuts && !score_queue.empty()) { + i_t cuts_processed = 0; + while (cuts_processed < max_cuts && !score_queue.empty()) { if (toc(start_time) >= settings.time_limit) { break; } // Get the row with the highest score from the queue auto [max_score, i] = score_queue.top(); score_queue.pop(); // skip stale score entries if (max_score != scores[i]) { continue; } + ++cuts_processed; // Add the current row to the aggregated set aggregated_mark[i] = 1; @@ -5304,7 +5342,8 @@ void complemented_mixed_integer_rounding_cut_t::bound_substitution( const variable_bounds_t& variable_bounds, const std::vector& var_types, const std::vector& xstar, - std::vector& transformed_xstar) + std::vector& transformed_xstar, + bool prefer_variable_bound_on_tie) { transformed_xstar.resize(lp.num_cols); // Perform bound substitution for continuous variables @@ -5368,8 +5407,12 @@ void complemented_mixed_integer_rounding_cut_t::bound_substitution( bound_changed_[j] = 0; continue; } - if (has_finite_lower_bound && - (!has_finite_upper_bound || (xstar_j - lb_star_[j] <= ub_star_[j] - xstar_j))) { + const f_t lower_distance = xstar_j - lb_star_[j]; + const f_t upper_distance = ub_star_[j] - xstar_j; + const bool prefer_upper_variable_bound = + prefer_variable_bound_on_tie && ub_variable_[j] >= 0 && lower_distance == upper_distance; + if (has_finite_lower_bound && (!has_finite_upper_bound || (lower_distance <= upper_distance && + !prefer_upper_variable_bound))) { // Use the lower bound // lb_star_j <= x_j <= ub_star_j // v_j = x_j - lb_star_j, @@ -5693,7 +5736,10 @@ f_t complemented_mixed_integer_rounding_cut_t::compute_violation( template void complemented_mixed_integer_rounding_cut_t::substitute_slacks( - const lp_problem_t& lp, csr_matrix_t& Arow, inequality_t& cut) + const lp_problem_t& lp, + csr_matrix_t& Arow, + inequality_t& cut, + f_t* work_estimate) { // Remove slacks from the cut // So that the cut is only over the original variables @@ -5701,6 +5747,7 @@ void complemented_mixed_integer_rounding_cut_t::substitute_slacks( i_t cut_nz = 0; std::vector cut_indices; cut_indices.reserve(cut.size()); + if (work_estimate != nullptr) { *work_estimate += cut.size(); } for (i_t k = 0; k < cut.size(); k++) { const i_t j = cut.index(k); @@ -5743,6 +5790,7 @@ void complemented_mixed_integer_rounding_cut_t::substitute_slacks( cut.rhs -= cj * lp.rhs[i] / alpha; const i_t row_start = Arow.row_start[i]; const i_t row_end = Arow.row_start[i + 1]; + if (work_estimate != nullptr) { *work_estimate += row_end - row_start; } for (i_t q = row_start; q < row_end; q++) { const i_t h = Arow.j[q]; if (h != j) { @@ -5764,6 +5812,9 @@ void complemented_mixed_integer_rounding_cut_t::substitute_slacks( if (found_slack) { scratch_pad_.get_pad(cut.vector.i, cut.vector.x); + if (work_estimate != nullptr) { + *work_estimate += 2 * cut.size() + cut.size() * std::log2((f_t)cut.size() + (f_t)1.0); + } // Sort the cut cut.sort(); } diff --git a/cpp/src/cuts/cuts.hpp b/cpp/src/cuts/cuts.hpp index 78091c85f6..fcb6080178 100644 --- a/cpp/src/cuts/cuts.hpp +++ b/cpp/src/cuts/cuts.hpp @@ -286,6 +286,23 @@ std::vector> find_violated_odd_cycles_for_test( double min_violation, double time_limit); +// Test-only helper to run the production sparse GF(2) row-dependency finder used +// by general zero-half cuts. Each parity row contains the integer-variable +// indices with an odd coefficient. A returned combination has even aggregate +// parity and an odd aggregate rhs. +std::vector> find_mod2_row_combinations_for_test( + const std::vector>& parity_rows, + const std::vector& rhs_parity, + int max_combination_size, + int max_combinations); +std::vector> find_mod2_row_combinations_for_test( + const std::vector>& parity_rows, + const std::vector& rhs_parity, + int max_combination_size, + int max_combinations, + double max_work_estimate, + double* work_estimate); + template class cut_pool_t { public: @@ -346,6 +363,18 @@ class cut_pool_t { template class variable_bounds_t; +template +bool generate_mod2_zero_half_cuts(cut_pool_t& cut_pool, + const simplex::lp_problem_t& lp, + const simplex::simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const std::vector& new_slacks, + const std::vector& var_types, + const std::vector& xstar, + variable_bounds_t& variable_bounds, + f_t start_time, + f_t& work_estimate); + template struct flow_cover_row_t { i_t row; @@ -543,7 +572,8 @@ class knapsack_generation_t { const std::vector& var_types, const std::vector& xstar, i_t knapsack_row, - inequality_t& cut); + inequality_t& cut, + f_t start_time); i_t num_knapsack_constraints() const { return knapsack_constraints_.size(); } const std::vector& get_knapsack_constraints() const { return knapsack_constraints_; } @@ -568,7 +598,8 @@ class knapsack_generation_t { const inequality_t& base_cut, const std::vector& c1_partition, const std::vector& c2_partition, - inequality_t& lifted_cut); + inequality_t& lifted_cut, + f_t start_time); // Solve a 0-1 knapsack problem using dynamic programming f_t solve_knapsack_problem(const std::vector& values, @@ -579,7 +610,8 @@ class knapsack_generation_t { f_t exact_knapsack_problem_integer_values_fraction_values(const std::vector& values, const std::vector& weights, f_t rhs, - std::vector& solution); + std::vector& solution, + f_t start_time); std::vector is_slack_; std::vector knapsack_constraints_; @@ -709,12 +741,16 @@ class cut_generation_t { const std::vector& reduced_costs, f_t start_time); - // Generate zero-half (odd-cycle / odd-wheel) cuts from the conflict graph + // Generate general row-parity zero-half cuts and conflict-graph + // odd-cycle / odd-wheel cuts. bool generate_zero_half_cuts(const simplex::lp_problem_t& lp, const simplex::simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const std::vector& new_slacks, const std::vector& var_types, const std::vector& xstar, const std::vector& reduced_costs, + variable_bounds_t& variable_bounds, f_t start_time); // Generate implied bounds cuts from probing implications @@ -955,7 +991,8 @@ class complemented_mixed_integer_rounding_cut_t { const variable_bounds_t& variable_bounds, const std::vector& var_types, const std::vector& xstar, - std::vector& transformed_xstar); + std::vector& transformed_xstar, + bool prefer_variable_bound_on_tie = false); // Converts an inequality of the form: sum_j a_j x_j >= beta // with l_j <= x_j <= u_j into the form: @@ -998,6 +1035,15 @@ class complemented_mixed_integer_rounding_cut_t { const std::vector& var_types, inequality_t& cut); + // Generate a lifted mixed-binary cover inequality from a transformed + // nonnegative >= row. Returns the cut in the same >= convention. + bool generate_lifted_mixed_binary_cover(const inequality_t& transformed_inequality, + const std::vector& var_types, + const std::vector& transformed_xstar, + inequality_t& transformed_cut, + f_t& work_estimate, + f_t max_work_estimate); + f_t compute_violation(const inequality_t& cut, const std::vector& xstar); f_t new_upper(i_t j) const { return transformed_upper_[j]; } @@ -1011,7 +1057,8 @@ class complemented_mixed_integer_rounding_cut_t { void substitute_slacks(const simplex::lp_problem_t& lp, csr_matrix_t& Arow, - inequality_t& cut); + inequality_t& cut, + f_t* work_estimate = nullptr); // Combine the pivot row with the inequality to eliminate the variable j // The new inequality is returned in inequality and inequality_rhs diff --git a/cpp/src/cuts/zero_half_mod2.cpp b/cpp/src/cuts/zero_half_mod2.cpp new file mode 100644 index 0000000000..d0c6a99aea --- /dev/null +++ b/cpp/src/cuts/zero_half_mod2.cpp @@ -0,0 +1,770 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +using simplex::lp_problem_t; +using simplex::simplex_solver_settings_t; +using simplex::variable_type_t; + +namespace { + +template +void symmetric_difference_sorted(const std::vector& a, + const std::vector& b, + std::vector& result) +{ + result.clear(); + result.reserve(a.size() + b.size()); + std::set_symmetric_difference(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(result)); +} + +template +struct mod2_parity_row_t { + std::vector parity; + bool rhs_parity{false}; +}; + +template +struct mod2_candidate_t : mod2_parity_row_t { + inequality_t transformed_inequality; + bool reversible{false}; +}; + +template +struct mod2_basis_row_t { + std::vector parity; + std::vector combination; + bool rhs{false}; +}; + +template +struct mod2_row_order_t { + const std::vector& rows; + + bool operator()(i_t a, i_t b) const + { + if (rows[a].parity.size() != rows[b].parity.size()) { + return rows[a].parity.size() < rows[b].parity.size(); + } + return rows[a].rhs_parity < rows[b].rhs_parity; + } +}; + +template +std::vector> find_mod2_row_combinations(const std::vector& rows, + i_t max_combination_size, + i_t max_combinations, + f_t* work_estimate, + f_t max_work_estimate, + f_t start_time = 0.0, + f_t time_limit = inf) +{ + cuopt_assert(max_combination_size > 0, "Maximum GF(2) combination size must be positive"); + cuopt_assert(max_combinations > 0, "Maximum number of GF(2) combinations must be positive"); + + i_t max_index = -1; + f_t input_scan_work = 0.0; + for (const auto& row : rows) { + input_scan_work += row.parity.size() + 1; + cuopt_assert(std::is_sorted(row.parity.begin(), row.parity.end()), + "GF(2) parity rows must be sorted"); + cuopt_assert(std::adjacent_find(row.parity.begin(), row.parity.end()) == row.parity.end(), + "GF(2) parity rows must not contain duplicates"); + if (!row.parity.empty()) { + cuopt_assert(row.parity.front() >= 0, "GF(2) parity index must be nonnegative"); + max_index = std::max(max_index, row.parity.back()); + } + } + if (add_work_estimate(input_scan_work, work_estimate, max_work_estimate)) { return {}; } + + std::vector permutation(rows.size()); + std::iota(permutation.begin(), permutation.end(), 0); + const f_t sort_work = permutation.size() * std::log2(permutation.size() + 1.0); + if (add_work_estimate(sort_work, work_estimate, max_work_estimate)) { return {}; } + // this is to process small/sparse rows first, for faster perf and smaller combinations + std::stable_sort(permutation.begin(), permutation.end(), mod2_row_order_t{rows}); + + if (add_work_estimate((f_t)(max_index + 1), work_estimate, max_work_estimate)) { return {}; } + std::vector pivot_to_basis((size_t)(max_index + 1), -1); + std::vector> basis; + basis.reserve(std::min(rows.size(), (size_t)(max_index + 1))); + std::vector> combinations; + combinations.reserve(std::min((size_t)max_combinations, rows.size())); + + std::vector parity_tmp; + std::vector combination_tmp; + for (const i_t candidate : permutation) { + if (toc(start_time) >= time_limit) { break; } + f_t candidate_work = rows[candidate].parity.size() + 2; + mod2_basis_row_t current; + current.parity = rows[candidate].parity; + current.combination = {candidate}; + current.rhs = rows[candidate].rhs_parity; + + bool abandoned = false; + while (!current.parity.empty()) { + const i_t pivot = current.parity.front(); + const i_t basis_index = pivot_to_basis[pivot]; + // pivot has not been seen before + if (basis_index < 0) { break; } + + const auto& pivot_row = basis[basis_index]; + candidate_work += current.parity.size() + pivot_row.parity.size() + + current.combination.size() + pivot_row.combination.size(); + symmetric_difference_sorted(current.parity, pivot_row.parity, parity_tmp); + symmetric_difference_sorted(current.combination, pivot_row.combination, combination_tmp); + if (combination_tmp.size() > (size_t)max_combination_size) { + abandoned = true; + break; + } + current.parity.swap(parity_tmp); + current.combination.swap(combination_tmp); + current.rhs = current.rhs != pivot_row.rhs; + } + if (add_work_estimate(candidate_work, work_estimate, max_work_estimate)) { break; } + if (abandoned) { continue; } + + // when reduced, add to combinations and continue, don't add to basis + if (current.parity.empty()) { + if (current.rhs && !current.combination.empty()) { + combinations.push_back(std::move(current.combination)); + if (combinations.size() >= (size_t)max_combinations) { break; } + } + continue; + } + + const i_t pivot = current.parity.front(); + pivot_to_basis[pivot] = basis.size(); + basis.push_back(std::move(current)); + } + return combinations; +} + +template +i_t mod2_integral_scale(const inequality_t& inequality, + const std::vector& var_types, + const std::vector& transformed_xstar, + i_t max_integral_scale, + f_t row_tight_tol, + f_t coefficient_integral_tol, + f_t start_time, + f_t time_limit, + f_t& work_estimate, + f_t max_work_estimate, + bool& work_limit_reached) +{ + for (i_t scale = 1; scale <= max_integral_scale; ++scale) { + if (toc(start_time) >= time_limit || work_limit_reached) { return i_t{0}; } + f_t scale_work = 1.0; + bool integral = true; + const f_t scaled_rhs = scale * inequality.rhs; + if (std::abs(scaled_rhs - std::round(scaled_rhs)) > + coefficient_integral_tol * std::max((f_t)1.0, std::abs(scaled_rhs))) { + integral = false; + } + for (i_t k = 0; integral && k < (i_t)inequality.size(); ++k) { + scale_work += 1.0; + const i_t j = inequality.index(k); + if (var_types[j] == variable_type_t::CONTINUOUS || transformed_xstar[j] <= row_tight_tol) { + continue; + } + const f_t scaled_coefficient = scale * inequality.coeff(k); + if (std::abs(scaled_coefficient - std::round(scaled_coefficient)) > + coefficient_integral_tol * std::max((f_t)1.0, std::abs(scaled_coefficient))) { + integral = false; + } + } + if (add_work_estimate(scale_work, &work_estimate, max_work_estimate, &work_limit_reached)) { + return i_t{0}; + } + if (integral) { return scale; } + } + return i_t{0}; +} + +template +std::vector> mod2_collect_candidates( + complemented_mixed_integer_rounding_cut_t& complemented_mir, + const lp_problem_t& lp, + csr_matrix_t& Arow, + const variable_bounds_t& variable_bounds, + const std::vector& var_types, + const std::vector& transformed_xstar, + f_t start_time, + f_t time_limit, + f_t& work_estimate, + f_t max_work_estimate, + bool& work_limit_reached) +{ + constexpr i_t max_integral_scale = 1000; + const i_t max_integer_row_length = 1000 + lp.num_cols / 10; + constexpr f_t row_tight_tol = 1e-6; + constexpr f_t coefficient_integral_tol = 1e-6; + + std::vector> candidates; + candidates.reserve(lp.num_rows); + for (i_t row = 0; row < lp.num_rows; ++row) { + if (toc(start_time) >= time_limit || work_limit_reached) { break; } + const i_t slack = complemented_mir.slack_cols(row); + if (slack < 0 || transformed_xstar[slack] > row_tight_tol) { continue; } + + const i_t row_length = Arow.row_start[row + 1] - Arow.row_start[row]; + if (row_length > max_integer_row_length) { continue; } + const f_t row_work = (8 * row_length + 5) + row_length * std::log2(row_length + 1.0); + if (add_work_estimate(row_work, &work_estimate, max_work_estimate, &work_limit_reached)) { + break; + } + inequality_t inequality(Arow, row, lp.rhs[row]); + complemented_mir.transform_inequality(variable_bounds, var_types, inequality); + inequality.sort(); + + // Every LP row is an equality after slack insertion. Remove a zero-valued transformed slack + // in the direction that preserves a valid >= inequality. + i_t slack_position = -1; + for (i_t k = 0; k < (i_t)inequality.size(); ++k) { + if (inequality.index(k) == slack) { + slack_position = k; + break; + } + } + if (slack_position < 0 || inequality.coeff(slack_position) == 0.0) { continue; } + // we want a row that is a.x >= b + if (inequality.coeff(slack_position) > 0.0) { inequality.negate(); } + inequality.vector.x[slack_position] = 0.0; + inequality_t squeezed_inequality(lp.num_cols); + inequality.squeeze(squeezed_inequality); + inequality = std::move(squeezed_inequality); + + // Continuous variables must be at their selected bounds to participate in the parity system. + bool continuous_at_bounds = true; + for (i_t k = 0; k < (i_t)inequality.size(); ++k) { + const i_t j = inequality.index(k); + if (var_types[j] == variable_type_t::CONTINUOUS && + std::abs(inequality.coeff(k)) > coefficient_integral_tol && + transformed_xstar[j] > row_tight_tol) { + continuous_at_bounds = false; + break; + } + } + if (!continuous_at_bounds) { continue; } + + const i_t scale = mod2_integral_scale(inequality, + var_types, + transformed_xstar, + max_integral_scale, + row_tight_tol, + coefficient_integral_tol, + start_time, + time_limit, + work_estimate, + max_work_estimate, + work_limit_reached); + // no integral scale found or time limit reached + if (scale == 0) { continue; } + if (scale != 1) { inequality.scale(scale); } + + mod2_candidate_t candidate; + candidate.transformed_inequality = std::move(inequality); + candidate.rhs_parity = (std::abs(std::llround(candidate.transformed_inequality.rhs)) % 2) != 0; + // checks if this could be safely reversed + candidate.reversible = std::abs(lp.upper[slack] - lp.lower[slack]) <= row_tight_tol; + for (i_t k = 0; k < (i_t)candidate.transformed_inequality.size(); ++k) { + const i_t j = candidate.transformed_inequality.index(k); + if (var_types[j] == variable_type_t::CONTINUOUS || transformed_xstar[j] <= row_tight_tol) { + continue; + } + const auto coefficient = std::llround(candidate.transformed_inequality.coeff(k)); + if ((std::abs(coefficient) % 2) != 0) { candidate.parity.push_back(j); } + } + if (candidate.parity.size() > (size_t)max_integer_row_length) { continue; } + candidates.push_back(std::move(candidate)); + } + return candidates; +} + +template +void mod2_add_transformed_zero_half_cut( + complemented_mixed_integer_rounding_cut_t& complemented_mir, + cut_pool_t& cut_pool, + const lp_problem_t& lp, + csr_matrix_t& Arow, + const variable_bounds_t& variable_bounds, + const std::vector& var_types, + const std::vector& xstar, + inequality_t transformed_cut, + f_t min_violation, + f_t& work_estimate, + i_t& cuts_added) +{ + work_estimate += 4 * transformed_cut.size() + 1; + complemented_mir.untransform_inequality(variable_bounds, var_types, transformed_cut); + complemented_mir.remove_small_coefficients(lp.lower, lp.upper, transformed_cut); + complemented_mir.substitute_slacks(lp, Arow, transformed_cut, &work_estimate); + complemented_mir.remove_small_coefficients(lp.lower, lp.upper, transformed_cut); + const f_t violation = complemented_mir.compute_violation(transformed_cut, xstar); + if (violation > min_violation) { + const i_t pool_size = cut_pool.pool_size(); + cut_pool.add_cut(cut_type_t::ZERO_HALF, transformed_cut); + if (cut_pool.pool_size() > pool_size) { ++cuts_added; } + } +} + +template +void mod2_generate_cuts_from_aggregate( + complemented_mixed_integer_rounding_cut_t& complemented_mir, + cut_pool_t& cut_pool, + const lp_problem_t& lp, + const simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const variable_bounds_t& variable_bounds, + const std::vector& var_types, + const std::vector& xstar, + const std::vector& transformed_xstar, + const inequality_t& oriented_aggregate, + f_t min_violation, + f_t start_time, + f_t& work_estimate, + f_t max_work_estimate, + bool& work_limit_reached, + i_t& cuts_added) +{ + if (add_work_estimate((f_t)(3 * oriented_aggregate.size() + 1), + &work_estimate, + max_work_estimate, + &work_limit_reached)) { + return; + } + inequality_t mir_cut(lp.num_cols); + const bool mir_cut_generated = complemented_mir.generate_cut_nonnegative_maintain_indicies( + oriented_aggregate, var_types, mir_cut); + if (mir_cut_generated) { + mod2_add_transformed_zero_half_cut(complemented_mir, + cut_pool, + lp, + Arow, + variable_bounds, + var_types, + xstar, + std::move(mir_cut), + min_violation, + work_estimate, + cuts_added); + } + + if (work_estimate > max_work_estimate) { + work_limit_reached = true; + return; + } + inequality_t lifted_cover_cut(lp.num_cols); + bool lifted_cover_cut_generated = false; + if (toc(start_time) < settings.time_limit) { + lifted_cover_cut_generated = + complemented_mir.generate_lifted_mixed_binary_cover(oriented_aggregate, + var_types, + transformed_xstar, + lifted_cover_cut, + work_estimate, + max_work_estimate); + } + if (lifted_cover_cut_generated) { + mod2_add_transformed_zero_half_cut(complemented_mir, + cut_pool, + lp, + Arow, + variable_bounds, + var_types, + xstar, + std::move(lifted_cover_cut), + min_violation, + work_estimate, + cuts_added); + } + if (work_estimate > max_work_estimate) { work_limit_reached = true; } +} + +template +struct lifted_cover_order_t { + const std::vector& solution_value; + const inequality_t& base; + f_t tolerance; + + bool operator()(int a, int b) const + { + const bool a_at_upper = solution_value[a] >= 1.0 - tolerance; + const bool b_at_upper = solution_value[b] >= 1.0 - tolerance; + if (a_at_upper != b_at_upper) { return a_at_upper; } + const f_t contribution_a = solution_value[a] * base.coeff(a); + const f_t contribution_b = solution_value[b] * base.coeff(b); + if (contribution_a != contribution_b) { return contribution_a > contribution_b; } + return base.coeff(a) > base.coeff(b); + } +}; + +template +f_t lifted_cover_coefficient( + f_t coefficient, const std::vector& prefix, size_t p, f_t lambda, f_t tolerance) +{ + for (size_t h = 0; h < p; ++h) { + if (coefficient <= prefix[h] - lambda + tolerance) { return h * lambda; } + if (coefficient <= prefix[h] + tolerance) { return (h + 1) * lambda + coefficient - prefix[h]; } + } + return p * lambda + coefficient - prefix[p - 1]; +} + +} // namespace + +std::vector> find_mod2_row_combinations_for_test( + const std::vector>& parity_rows, + const std::vector& rhs_parity, + int max_combination_size, + int max_combinations) +{ + return find_mod2_row_combinations_for_test(parity_rows, + rhs_parity, + max_combination_size, + max_combinations, + std::numeric_limits::infinity(), + nullptr); +} + +std::vector> find_mod2_row_combinations_for_test( + const std::vector>& parity_rows, + const std::vector& rhs_parity, + int max_combination_size, + int max_combinations, + double max_work_estimate, + double* work_estimate_out) +{ + cuopt_assert(parity_rows.size() == rhs_parity.size(), + "GF(2) parity row and rhs sizes must match"); + std::vector> rows; + rows.reserve(parity_rows.size()); + for (size_t i = 0; i < parity_rows.size(); ++i) { + rows.push_back({parity_rows[i], rhs_parity[i] != 0}); + } + + double work_estimate = 0.0; + auto combinations = find_mod2_row_combinations( + rows, max_combination_size, max_combinations, &work_estimate, max_work_estimate); + if (work_estimate_out != nullptr) { *work_estimate_out = work_estimate; } + return combinations; +} + +template +bool generate_mod2_zero_half_cuts(cut_pool_t& cut_pool, + const lp_problem_t& lp, + const simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const std::vector& new_slacks, + const std::vector& var_types, + const std::vector& xstar, + variable_bounds_t& variable_bounds, + f_t start_time, + f_t& work_estimate) +{ + constexpr i_t max_combination_size = 64; + constexpr i_t max_row_combinations = 1000; + constexpr f_t min_violation = 1e-6; + constexpr f_t candidate_work_limit = 3e7; + constexpr f_t combination_work_limit = 3e7; + constexpr f_t generation_work_limit = 4e7; + f_t candidate_work = 0.0; + f_t combination_work = 0.0; + f_t generation_work = 0.0; + bool candidate_limit_reached = false; + bool generation_limit_reached = false; + + if (add_work_estimate((f_t)(3 * lp.num_cols) + (f_t)(variable_bounds.upper_variables.size() + + variable_bounds.lower_variables.size()), + &candidate_work, + candidate_work_limit, + &candidate_limit_reached)) { + work_estimate = candidate_work; + return false; + } + complemented_mixed_integer_rounding_cut_t complemented_mir(lp, settings, new_slacks); + std::vector transformed_xstar; + complemented_mir.bound_substitution( + lp, variable_bounds, var_types, xstar, transformed_xstar, true); + + auto candidates = mod2_collect_candidates(complemented_mir, + lp, + Arow, + variable_bounds, + var_types, + transformed_xstar, + start_time, + settings.time_limit, + candidate_work, + candidate_work_limit, + candidate_limit_reached); + + if (toc(start_time) >= settings.time_limit) { + work_estimate = candidate_work; + return true; + } + auto row_combinations = find_mod2_row_combinations(candidates, + max_combination_size, + max_row_combinations, + &combination_work, + combination_work_limit, + start_time, + settings.time_limit); + if (add_work_estimate((f_t)(2 * lp.num_cols), + &generation_work, + generation_work_limit, + &generation_limit_reached)) { + work_estimate = candidate_work + combination_work + generation_work; + return true; + } + scratch_pad_t aggregate_pad(lp.num_cols); + + for (const auto& combination : row_combinations) { + if (toc(start_time) >= settings.time_limit || generation_limit_reached) { break; } + + size_t aggregate_input_nz = 0; + for (const i_t candidate_index : combination) { + aggregate_input_nz += candidates[candidate_index].transformed_inequality.size(); + } + if (add_work_estimate((f_t)(2 * aggregate_input_nz + 1), + &generation_work, + generation_work_limit, + &generation_limit_reached)) { + break; + } + + inequality_t aggregate(lp.num_cols); + bool reversible = true; + for (const i_t candidate_index : combination) { + const auto& candidate = candidates[candidate_index]; + aggregate.rhs += candidate.transformed_inequality.rhs; + reversible = reversible && candidate.reversible; + for (i_t k = 0; k < (i_t)candidate.transformed_inequality.size(); ++k) { + aggregate_pad.add_to_pad(candidate.transformed_inequality.index(k), + candidate.transformed_inequality.coeff(k)); + } + } + aggregate_pad.get_pad(aggregate.vector.i, aggregate.vector.x); + aggregate_pad.clear_pad(); + const f_t aggregate_output_work = + 3 * aggregate.size() + aggregate.size() * std::log2(aggregate.size() + 1.0); + if (add_work_estimate(aggregate_output_work, + &generation_work, + generation_work_limit, + &generation_limit_reached)) { + break; + } + aggregate.sort(); + aggregate.scale(0.5); + + i_t cuts_added = 0; + mod2_generate_cuts_from_aggregate(complemented_mir, + cut_pool, + lp, + settings, + Arow, + variable_bounds, + var_types, + xstar, + transformed_xstar, + aggregate, + min_violation, + start_time, + generation_work, + generation_work_limit, + generation_limit_reached, + cuts_added); + // if the final inequality is reversable, try the reversed version as well + if (reversible && toc(start_time) < settings.time_limit && !generation_limit_reached) { + aggregate.negate(); + mod2_generate_cuts_from_aggregate(complemented_mir, + cut_pool, + lp, + settings, + Arow, + variable_bounds, + var_types, + xstar, + transformed_xstar, + aggregate, + min_violation, + start_time, + generation_work, + generation_work_limit, + generation_limit_reached, + cuts_added); + } + } + work_estimate = candidate_work + combination_work + generation_work; + return true; +} + +template +bool complemented_mixed_integer_rounding_cut_t::generate_lifted_mixed_binary_cover( + const inequality_t& transformed_inequality, + const std::vector& var_types, + const std::vector& transformed_xstar, + inequality_t& transformed_cut, + f_t& work_estimate, + f_t max_work_estimate) +{ + constexpr f_t tolerance = 1e-6; + + const f_t estimated_work = + 12 * transformed_inequality.size() + + transformed_inequality.size() * std::log2(transformed_inequality.size() + 1.0); + if (add_work_estimate(estimated_work, &work_estimate, max_work_estimate)) { return false; } + + inequality_t base = transformed_inequality; + base.negate(); + + std::vector locally_complemented(base.size(), 0); + std::vector solution_value(base.size(), 0.0); + std::vector is_integral(base.size(), 0); + for (i_t k = 0; k < (i_t)base.size(); ++k) { + const i_t j = base.index(k); + f_t aj = base.coeff(k); + if (var_types[j] == variable_type_t::CONTINUOUS) { + solution_value[k] = transformed_xstar[j]; + if (aj > 0.0) { base.vector.x[k] = 0.0; } + continue; + } + + const f_t upper = new_upper(j); + if (upper == inf || std::abs(upper - 1.0) > tolerance) { return false; } + is_integral[k] = 1; + if (aj < 0.0) { + base.rhs -= aj * upper; + base.vector.x[k] = -aj; + solution_value[k] = upper - transformed_xstar[j]; + locally_complemented[k] = 1; + } else { + solution_value[k] = transformed_xstar[j]; + } + } + + std::vector cover; + cover.reserve(base.size()); + for (i_t k = 0; k < (i_t)base.size(); ++k) { + if (is_integral[k] && base.coeff(k) > tolerance && solution_value[k] > tolerance) { + cover.push_back(k); + } + } + if (cover.empty()) { return false; } + + std::stable_sort( + cover.begin(), cover.end(), lifted_cover_order_t{solution_value, base, tolerance}); + + f_t cover_weight = 0.0; + size_t cover_size = 0; + for (; cover_size < cover.size(); ++cover_size) { + cover_weight += base.coeff(cover[cover_size]); + if (cover_weight - base.rhs > tolerance * std::max((f_t)1.0, std::abs(base.rhs))) { + ++cover_size; + break; + } + } + if (cover_size == 0 || cover_size > cover.size()) { return false; } + cover.resize(cover_size); + + const f_t lambda = cover_weight - base.rhs; + if (lambda <= tolerance) { return false; } + std::sort( + cover.begin(), cover.end(), [&](i_t a, i_t b) { return base.coeff(a) > base.coeff(b); }); + + std::vector prefix(cover.size(), 0.0); + std::vector in_cover(base.size(), 0); + f_t prefix_sum = 0.0; + size_t p = cover.size(); + for (size_t h = 0; h < cover.size(); ++h) { + const i_t k = cover[h]; + in_cover[k] = 1; + if (base.coeff(k) - lambda <= tolerance && p == cover.size()) { p = h; } + if (h < p) { + prefix_sum += base.coeff(k); + prefix[h] = prefix_sum; + } + } + if (p == 0) { return false; } + + size_t non_cover_count = 0; + for (i_t k = 0; k < (i_t)base.size(); ++k) { + if (is_integral[k] && !in_cover[k]) { non_cover_count++; } + } + if (add_work_estimate((f_t)(non_cover_count * p), &work_estimate, max_work_estimate)) { + return false; + } + + transformed_cut = base; + transformed_cut.rhs = -lambda; + for (i_t k = 0; k < (i_t)base.size(); ++k) { + if (!is_integral[k]) { + if (base.coeff(k) >= 0.0) { transformed_cut.vector.x[k] = 0.0; } + continue; + } + if (in_cover[k]) { + transformed_cut.vector.x[k] = std::min(base.coeff(k), lambda); + transformed_cut.rhs += transformed_cut.coeff(k); + } else { + transformed_cut.vector.x[k] = + lifted_cover_coefficient(base.coeff(k), prefix, p, lambda, tolerance); + } + } + + for (i_t k = 0; k < (i_t)transformed_cut.size(); ++k) { + if (!locally_complemented[k]) { continue; } + const i_t j = transformed_cut.index(k); + const f_t coefficient = transformed_cut.coeff(k); + transformed_cut.rhs -= coefficient * new_upper(j); + transformed_cut.vector.x[k] = -coefficient; + } + inequality_t squeezed_cut(transformed_cut.vector.n); + transformed_cut.squeeze(squeezed_cut); + transformed_cut = std::move(squeezed_cut); + transformed_cut.negate(); + return true; +} + +#ifdef DUAL_SIMPLEX_INSTANTIATE_DOUBLE +template bool generate_mod2_zero_half_cuts( + cut_pool_t& cut_pool, + const lp_problem_t& lp, + const simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const std::vector& new_slacks, + const std::vector& var_types, + const std::vector& xstar, + variable_bounds_t& variable_bounds, + double start_time, + double& work_estimate); + +template bool +complemented_mixed_integer_rounding_cut_t::generate_lifted_mixed_binary_cover( + const inequality_t& transformed_inequality, + const std::vector& var_types, + const std::vector& transformed_xstar, + inequality_t& transformed_cut, + double& work_estimate, + double max_work_estimate); +#endif + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/tests/mip/cuts_test.cu b/cpp/tests/mip/cuts_test.cu index b4fc3e8cc7..5af0754e3d 100644 --- a/cpp/tests/mip/cuts_test.cu +++ b/cpp/tests/mip/cuts_test.cu @@ -422,7 +422,7 @@ void disable_non_clique_cuts(mip_solver_settings_t& settings) void disable_non_zero_half_cuts(mip_solver_settings_t& settings) { - settings.clique_cuts = 1; + settings.clique_cuts = 0; settings.zero_half_cuts = 1; settings.max_cut_passes = 10; settings.mixed_integer_gomory_cuts = 0; @@ -1482,6 +1482,57 @@ TEST(cuts, zero_half_unit_separator_simple_pentagon) EXPECT_TRUE(found); } +TEST(cuts, zero_half_unit_mod2_row_finder_single_pair_and_four_row_dependencies) +{ + // Empty parity with odd rhs is a one-row zero-half aggregation. + { + const std::vector> parity_rows = {{}}; + const std::vector rhs_parity = {1}; + const auto combinations = + mip::find_mod2_row_combinations_for_test(parity_rows, rhs_parity, 8, 8); + ASSERT_EQ(combinations.size(), 1); + EXPECT_EQ(combinations.front(), std::vector{0}); + } + + // Equal parity and opposite rhs form a two-row dependency. + { + const std::vector> parity_rows = {{0, 2}, {0, 2}}; + const std::vector rhs_parity = {0, 1}; + const auto combinations = + mip::find_mod2_row_combinations_for_test(parity_rows, rhs_parity, 8, 8); + ASSERT_EQ(combinations.size(), 1); + EXPECT_EQ(combinations.front(), (std::vector{0, 1})); + } + + // Four edges of an even cycle cancel in GF(2); the odd aggregate rhs makes + // the dependency eligible for a zero-half cut. + { + const std::vector> parity_rows = {{0, 1}, {1, 2}, {2, 3}, {0, 3}}; + const std::vector rhs_parity = {1, 0, 0, 0}; + const auto combinations = + mip::find_mod2_row_combinations_for_test(parity_rows, rhs_parity, 8, 8); + ASSERT_EQ(combinations.size(), 1); + EXPECT_EQ(combinations.front(), (std::vector{0, 1, 2, 3})); + } +} + +TEST(cuts, zero_half_unit_mod2_row_finder_stops_at_work_limit) +{ + std::vector support(64); + std::iota(support.begin(), support.end(), 0); + const std::vector> parity_rows(256, support); + const std::vector rhs_parity(256, 0); + + constexpr double max_work = 18900.0; + double work = 0.0; + const auto combinations = + mip::find_mod2_row_combinations_for_test(parity_rows, rhs_parity, 64, 1000, max_work, &work); + + EXPECT_TRUE(combinations.empty()); + EXPECT_GT(work, max_work); + EXPECT_LT(work, 19100.0); +} + TEST(cuts, zero_half_unit_separator_no_cycle_for_4_cycle) { // Even cycle: 0-1-2-3-0 @@ -1615,6 +1666,28 @@ TEST(cuts, zero_half_end_to_end_pentagon_tightens_lp_relaxation) EXPECT_NEAR(mip_solution.get_objective_value(), -2.0, kCliqueTestTol); } +TEST(cuts, zero_half_end_to_end_general_row_parity_closes_triangle_root_gap) +{ + const raft::handle_t handle{}; + auto mip_problem = create_pairwise_triangle_set_packing_problem(); + + mip_solver_settings_t settings; + settings.time_limit = 10.0; + settings.presolver = presolver_t::None; + settings.node_limit = 0; + disable_non_zero_half_cuts(settings); + + benchmark_info_t benchmark_info; + settings.benchmark_info_ptr = &benchmark_info; + auto mip_solution = solve_mip(&handle, mip_problem, settings); + + EXPECT_NE(mip_solution.get_termination_status(), mip_termination_status_t::Infeasible); + ASSERT_FALSE(std::isnan(benchmark_info.root_lp_no_cuts)); + ASSERT_FALSE(std::isnan(benchmark_info.root_lp_with_cuts)); + EXPECT_NEAR(benchmark_info.root_lp_no_cuts, -1.5, kCliqueTestTol); + EXPECT_NEAR(benchmark_info.root_lp_with_cuts, -1.0, kCliqueTestTol); +} + TEST(cuts, zero_half_unit_separator_seven_cycle_violated_below_half) { // 7-cycle: 0-1-2-3-4-5-6-0, all weights 0.4. Each edge weight = (1-0.4-0.4)/2 = 0.1 diff --git a/skills/cuopt-developer/SKILL.md b/skills/cuopt-developer/SKILL.md index aa488e064b..4aae65c8be 100644 --- a/skills/cuopt-developer/SKILL.md +++ b/skills/cuopt-developer/SKILL.md @@ -224,7 +224,7 @@ For pre-commit setup, DCO sign-off (`git commit -s`), the fork-based PR workflow ## Coding Conventions -For C++ naming (`snake_case`, `d_`/`h_` prefixes, `_t` suffix), file extensions (`.hpp`/`.cpp`/`.cu`/`.cuh` and which compiler each uses), include order, Python style, error handling (`CUOPT_EXPECTS`, `RAFT_CUDA_TRY`), memory management (RMM patterns, no raw `new`/`delete`), test-impact rules, and volatile-comment rules (hardware names and self-referential issue/PR numbers in comments or skip messages go stale; issue links to a separate tracking issue are fine), see [references/conventions.md](references/conventions.md). +For C++ naming (`snake_case`, `d_`/`h_` prefixes, `_t` suffix), file extensions (`.hpp`/`.cpp`/`.cu`/`.cuh` and which compiler each uses), include order, Python style, error handling (`CUOPT_EXPECTS`, `RAFT_CUDA_TRY`), memory management (RMM patterns, no raw `new`/`delete`), test-impact rules, volatile-comment rules (hardware names and self-referential issue/PR numbers in comments or skip messages go stale; issue links to a separate tracking issue are fine), **no large local lambdas** (extract named helpers instead), and **coarse work-estimate / time-limit gating** (phase/outer-loop only; no fine inner-loop or double checks), see [references/conventions.md](references/conventions.md). ## OpenMP task/runtime compatibility diff --git a/skills/cuopt-developer/references/conventions.md b/skills/cuopt-developer/references/conventions.md index e9963d4824..8ab14eb205 100644 --- a/skills/cuopt-developer/references/conventions.md +++ b/skills/cuopt-developer/references/conventions.md @@ -55,9 +55,20 @@ This applies to all comment types: inline comments, block comments, suppression ## C++ Implementation Style -- Prefer direct loops or named helpers for performance-critical traversal logic. Reserve lambdas for - short predicates and callbacks; large local lambdas obscure control flow and can lead to repeated - scans. +- **Never write large lambdas inside a function body.** If a lambda is more than a short + predicate/comparator (roughly more than ~3–5 lines, or it has nested lambdas, local state, + or non-trivial control flow), extract it as a named free function, file-local helper in an + anonymous namespace, or private method. Large local lambdas obscure control flow, hide reuse, + and make work/time accounting harder to reason about. +- Prefer direct loops or named helpers for performance-critical traversal logic. Reserve + in-function lambdas for short predicates and callbacks only. +- Keep work-estimate and time-limit checks at phase or outer-loop boundaries. Accumulate the work + performed by cheap inner loops and charge it once when the phase completes; do not gate every + inner iteration. Do not separately test a sticky limit or raw estimate immediately before or + after `add_work_estimate` when that call already provides the gate for the same work. +- Charge the operation that is actually performed. For vector copies and sparse traversals, base + work on the number of visited or copied entries rather than only the number of containers. + Avoid charging the same traversal in both its caller and callee. ### Suppression comments (`// NOSONAR`, `// NOLINT`, etc.) From 301a9ac71db53c4e5990290c96d3a522c77900cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Akif=20=C3=87=C3=96RD=C3=9CK?= Date: Fri, 21 Aug 2026 14:22:55 +0200 Subject: [PATCH 02/78] Revert presolve tolerance (#1757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authors: - Akif ÇÖRDÜK (https://github.com/akifcorduk) Approvers: - Hugo Linsenmaier (https://github.com/hlinsen) URL: https://github.com/NVIDIA/cuopt/pull/1757 --- cpp/src/mip_heuristics/presolve/third_party_presolve.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp b/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp index fdb5a0c88b..3d324d2007 100644 --- a/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp +++ b/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp @@ -724,7 +724,7 @@ void set_presolve_options(papilo::Presolve& presolver, { presolver.getPresolveOptions().tlim = time_limit; presolver.getPresolveOptions().threads = num_cpu_threads; // user setting or 0 (automatic) - presolver.getPresolveOptions().feastol = absolute_tolerance; + presolver.getPresolveOptions().feastol = 1e-5; if (max_rounds > 0) { presolver.getPresolveOptions().maxrounds = max_rounds; } if (dual_postsolve) { presolver.getPresolveOptions().componentsmaxint = -1; From c7c94d2e6e5f00101e296dcb1239f02dd97632be Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu <42624703+ramakrishnap-nv@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:50:00 -0500 Subject: [PATCH 03/78] fix: keep host sources alive for async route-view copies (#1756) (#1759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1756 — `test_batch_solve_varying_sizes` aborted with a device-side `block_copy` assert on CUDA 13.3. `rmm::device_uvector::set_element_async` does not synchronize; the host source must stay valid until the stream is synced (`device_uvector.hpp:200-207`). Four sites in `solution.cu` copied a route `view_t` from a stack local that died each loop iteration, two of them without ever syncing. Since the whole `view_t` is copied, a dead source corrupts the span size and the pointers together — hence both the assert and the `cudaErrorIllegalAddress` seen in CI. Each site now stages its host sources in a vector reserved up front, so the staging outlives the copy. Also removes the CUDA 13.3 skip, since the test passes again. Note: only pull-request builds set `DEFINE_ASSERT`, so nightly and released builds were doing this out-of-bounds copy silently. Authors: - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv) Approvers: - Akif ÇÖRDÜK (https://github.com/akifcorduk) - Trevor McKay (https://github.com/tmckayus) URL: https://github.com/NVIDIA/cuopt/pull/1759 --- cpp/src/routing/solution/solution.cu | 74 ++++++++++++------- cpp/src/routing/solution/solution.cuh | 10 ++- .../cuopt/tests/routing/test_batch_solve.py | 8 -- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/cpp/src/routing/solution/solution.cu b/cpp/src/routing/solution/solution.cu index edd3bef9a4..cbf7ed9384 100644 --- a/cpp/src/routing/solution/solution.cu +++ b/cpp/src/routing/solution/solution.cu @@ -93,11 +93,10 @@ void solution_t::add_route(route_t&& route route.n_nodes.set_value_async(n_nodes, sol_handle->get_stream()); route.route_id.set_value_async(route_id, sol_handle->get_stream()); sol_handle->sync_stream(); - i_t route_slot = route_id_to_idx[route_id]; - routes[route_slot] = std::move(route); - const auto route_view = routes[route_slot].view(); + i_t route_slot = route_id_to_idx[route_id]; + routes[route_slot] = std::move(route); cuopt_assert(route_id < (int)routes_view.size(), "route id should be in range"); - routes_view.set_element_async(route_id, route_view, sol_handle->get_stream()); + set_route_views(route_id, route_id + 1); if (max_nodes_per_route < get_route(route_id).max_nodes_per_route()) { resize_routes(raft::alignTo(get_route(route_id).max_nodes_per_route(), base_route_size)); } @@ -122,6 +121,14 @@ void solution_t::add_routes( n_routes += added_routes; check_and_allocate_routes(n_routes); + // Host sources of the async copies below must stay valid and unmodified until the + // sync_stream() after this loop, so per-iteration locals cannot be used. vehicle_id and + // route are references into new_routes and already outlive this function; the scalars + // are staged here, reserved up front so no reallocation can invalidate a pending copy. + std::vector h_n_nodes; + std::vector h_route_ids; + h_n_nodes.reserve(new_routes.size()); + h_route_ids.reserve(new_routes.size()); for (const auto& [vehicle_id, route] : new_routes) { // depot for the beginning and the end i_t new_route_size = route.size() + 2; @@ -131,26 +138,22 @@ void solution_t::add_routes( std::max(max_nodes_per_route, raft::alignTo(new_route_size, base_route_size)); resize_routes(new_route_size); } - std::vector> node_info_h; - for (size_t x = 0; x < route.size(); ++x) { - // Dummy, will be overriden in set_nodes_data_of_route - node_info_h.emplace_back(route[x]); - } - // skip depot + // Values are overridden in set_nodes_data_of_route; copy straight out of new_routes, + // which outlives this function, rather than through a temporary. Skip the depot. raft::copy(d_route.dimensions.requests.node_info.data() + 1, - node_info_h.data(), - node_info_h.size(), + route.data(), + route.size(), sol_handle->get_stream()); - const i_t n_nodes = route.size() + 1; + const i_t& n_nodes = h_n_nodes.emplace_back(route.size() + 1); d_route.n_nodes.set_value_async(n_nodes, sol_handle->get_stream()); - d_route.route_id.set_value_async(route_id, sol_handle->get_stream()); + const i_t& stable_route_id = h_route_ids.emplace_back(route_id); + d_route.route_id.set_value_async(stable_route_id, sol_handle->get_stream()); d_route.vehicle_id.set_value_async(vehicle_id, sol_handle->get_stream()); - i_t route_slot = route_id_to_idx[route_id]; - const auto route_view = routes[route_slot].view(); cuopt_assert(route_id < (int)routes_view.size(), "route id should be in range"); - routes_view.set_element_async(route_id, route_view, sol_handle->get_stream()); ++route_id; } + // Publish once, through the single path that owns the lifetime rule. + set_route_views(prev_route_size, n_routes); set_nodes_data_of_new_routes(added_routes, prev_route_size); sol_handle->sync_stream(); } @@ -286,18 +289,32 @@ void solution_t::set_routes_to_search() } template -void solution_t::set_route_views() +void solution_t::set_route_views(i_t start, i_t end) { raft::common::nvtx::range fun_scope("set_route_views"); if (routes.size() > routes_view.size()) { // reserve with the max size routes_view.resize(routes.size(), sol_handle->get_stream()); } - - for (size_t i = 0; i < routes.size(); ++i) { - const auto route_view = get_route(i).view(); - routes_view.set_element_async(i, route_view, sol_handle->get_stream()); + if (end < 0) { end = (i_t)routes.size(); } + cuopt_assert(start >= 0 && end <= (i_t)routes.size(), "route view range out of bounds"); + if (end <= start) { return; } + + // Single point where route views are published to the device. The host source of an async + // copy must stay valid and unmodified until the stream is synchronized -- rmm's + // memcpy_async uses cudaMemcpySrcAccessOrderStream on CUDA 13, so the bytes are read when + // the copy runs, not when it is enqueued. Staging in a member buffer and synchronizing here + // keeps that rule in one place instead of at every call site. + h_routes_view.clear(); + h_routes_view.reserve(end - start); + for (i_t i = start; i < end; ++i) { + h_routes_view.push_back(get_route(i).view()); } + raft::copy(routes_view.data() + start, + h_routes_view.data(), + h_routes_view.size(), + sol_handle->get_stream()); + sol_handle->sync_stream(); } // init the rotues with the desired number of routes @@ -374,14 +391,17 @@ void solution_t::resize_routes(i_t new_size) { raft::common::nvtx::range fun_scope("resize_routes"); + bool any_resized = false; for (i_t i = 0; i < n_routes; ++i) { auto& route_i = get_route(i); if (route_i.max_nodes_per_route() < new_size) { route_i.resize(new_size); - const auto route_view = get_route(i).view(); - routes_view.set_element_async(i, route_view, sol_handle->get_stream()); + any_resized = true; } } + // A resize reallocates the route buffers, so the device-side views are stale. Republish + // through set_route_views rather than copying each changed view from a local here. + if (any_resized) { set_route_views(); } max_nodes_per_route = std::max(max_nodes_per_route, new_size); } @@ -512,10 +532,8 @@ void solution_t::copy_device_solution(solution_tget_stream()); - } + // copy_routes below reads these entries, so they must be published before it launches. + set_route_views(n_routes, src_sol.n_routes); n_routes = src_sol.n_routes; diff --git a/cpp/src/routing/solution/solution.cuh b/cpp/src/routing/solution/solution.cuh index 143414e69e..db00d09147 100644 --- a/cpp/src/routing/solution/solution.cuh +++ b/cpp/src/routing/solution/solution.cuh @@ -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 */ @@ -494,7 +494,8 @@ class solution_t { size_t get_temp_route_shared_size(i_t added_size = 0) const; void compute_initial_data(bool check_feasibility = true); void random_init_routes(); - void set_route_views(); + // Publishes routes[start, end) to the device. end < 0 means routes.size(). + void set_route_views(i_t start = 0, i_t end = -1); void expand_route(i_t route_id); void resize_route(i_t route_id, i_t new_route_size); void clear_routes(std::vector vehicle_ids); @@ -650,6 +651,11 @@ class solution_t { // we shouldn't access this directly as route ids map differently std::vector> routes; + // Host staging for routes_view, internal to set_route_views(). That function synchronizes + // before returning, so no copy is ever left pending against this buffer and it is safe to + // reuse across calls. Kept as a member so the paths that publish views do not allocate. + std::vector::view_t> h_routes_view; + public: static constexpr i_t fragment_step = 1; diff --git a/python/cuopt/cuopt/tests/routing/test_batch_solve.py b/python/cuopt/cuopt/tests/routing/test_batch_solve.py index 5099c89cb1..31d09c202c 100644 --- a/python/cuopt/cuopt/tests/routing/test_batch_solve.py +++ b/python/cuopt/cuopt/tests/routing/test_batch_solve.py @@ -2,9 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import cudf -import cupy import numpy as np -import pytest from cuopt import routing @@ -18,12 +16,6 @@ def create_tsp_cost_matrix(n_locations): return cudf.DataFrame(cost_matrix) -@pytest.mark.skipif( - 13030 <= cupy.cuda.get_local_runtime_version() < 13040, - reason="block_copy destination overflow on CUDA 13.3, see " - "https://github.com/NVIDIA/cuopt/issues/1756. The device-side assert " - "aborts the process, so xfail cannot catch it.", -) def test_batch_solve_varying_sizes(): """Test batch solving TSPs of varying sizes.""" tsp_sizes = [ From 380f7eeb038e1505aab26c25035c8a2e5334974e Mon Sep 17 00:00:00 2001 From: Alice Boucher <160623740+aliceb-nv@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:19:09 +0200 Subject: [PATCH 04/78] Block Bounded Value Elimination presolving pass for binary problems (#1683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Issue Authors: - Alice Boucher (https://github.com/aliceb-nv) Approvers: - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv) - Akif ÇÖRDÜK (https://github.com/akifcorduk) URL: https://github.com/NVIDIA/cuopt/pull/1683 --- ci/validate_wheel.sh | 2 +- .../mathematical_optimization/constants.h | 3 + .../mip/solver_settings.hpp | 9 + cpp/src/io/mps_writer.cpp | 4 +- cpp/src/math_optimization/solver_settings.cu | 1 + cpp/src/mip_heuristics/CMakeLists.txt | 1 + .../diversity/diversity_manager.cu | 19 +- .../mip_heuristics/diversity/population.cu | 6 + .../feasibility_jump/early_gpufj.cu | 4 +- cpp/src/mip_heuristics/presolve/block_bve.cu | 1553 +++++++++++++++++ cpp/src/mip_heuristics/presolve/block_bve.cuh | 167 ++ .../mip_heuristics/presolve/probing_cache.cu | 133 +- .../mip_heuristics/presolve/probing_cache.cuh | 17 + .../presolve/trivial_presolve.cuh | 6 +- .../mip_heuristics/problem/presolve_data.cu | 60 +- .../mip_heuristics/problem/presolve_data.cuh | 26 +- cpp/src/mip_heuristics/problem/problem.cu | 213 +-- cpp/src/mip_heuristics/problem/problem.cuh | 10 +- cpp/src/mip_heuristics/solve.cu | 2 +- cpp/src/utilities/integer_scaling.hpp | 217 +++ cpp/tests/internal/CMakeLists.txt | 1 + cpp/tests/mip/block_bve_test.cu | 936 ++++++++++ .../mip/block_bve/all_feasible_projection.mps | 25 + .../block_bve/all_infeasible_projection.mps | 54 + datasets/mip/block_bve/and_used.mps | 30 + datasets/mip/block_bve/aux_with_obj.mps | 28 + datasets/mip/block_bve/chain_or.mps | 40 + datasets/mip/block_bve/heavy_reduce.mps | 54 + datasets/mip/block_bve/infeasible.mps | 31 + datasets/mip/block_bve/mixed.mps | 55 + datasets/mip/block_bve/neq_used.mps | 37 + datasets/mip/block_bve/or_used.mps | 34 + datasets/mip/block_bve/random_a.mps | 37 + datasets/mip/block_bve/random_b.mps | 54 + datasets/mip/block_bve/random_c.mps | 48 + datasets/mip/block_bve/two_gadgets.mps | 49 + skills/cuopt-developer/SKILL.md | 3 +- .../cuopt-developer/references/conventions.md | 51 + 38 files changed, 3822 insertions(+), 198 deletions(-) create mode 100644 cpp/src/mip_heuristics/presolve/block_bve.cu create mode 100644 cpp/src/mip_heuristics/presolve/block_bve.cuh create mode 100644 cpp/src/utilities/integer_scaling.hpp create mode 100644 cpp/tests/mip/block_bve_test.cu create mode 100644 datasets/mip/block_bve/all_feasible_projection.mps create mode 100644 datasets/mip/block_bve/all_infeasible_projection.mps create mode 100644 datasets/mip/block_bve/and_used.mps create mode 100644 datasets/mip/block_bve/aux_with_obj.mps create mode 100644 datasets/mip/block_bve/chain_or.mps create mode 100644 datasets/mip/block_bve/heavy_reduce.mps create mode 100644 datasets/mip/block_bve/infeasible.mps create mode 100644 datasets/mip/block_bve/mixed.mps create mode 100644 datasets/mip/block_bve/neq_used.mps create mode 100644 datasets/mip/block_bve/or_used.mps create mode 100644 datasets/mip/block_bve/random_a.mps create mode 100644 datasets/mip/block_bve/random_b.mps create mode 100644 datasets/mip/block_bve/random_c.mps create mode 100644 datasets/mip/block_bve/two_gadgets.mps diff --git a/ci/validate_wheel.sh b/ci/validate_wheel.sh index 685d4b920d..a45c97b415 100755 --- a/ci/validate_wheel.sh +++ b/ci/validate_wheel.sh @@ -22,7 +22,7 @@ PYDISTCHECK_ARGS=( if [[ "${package_dir}" == "python/libcuopt" ]]; then if [[ "${RAPIDS_CUDA_MAJOR}" == "12" ]]; then PYDISTCHECK_ARGS+=( - --max-allowed-size-compressed '690Mi' + --max-allowed-size-compressed '695Mi' ) else PYDISTCHECK_ARGS+=( diff --git a/cpp/include/cuopt/mathematical_optimization/constants.h b/cpp/include/cuopt/mathematical_optimization/constants.h index 467aa7fce3..7128fcf0e9 100644 --- a/cpp/include/cuopt/mathematical_optimization/constants.h +++ b/cpp/include/cuopt/mathematical_optimization/constants.h @@ -146,6 +146,9 @@ #define CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_RATIO "mip_hyper_submip_iteration_limit_ratio" #define CUOPT_MIP_HYPER_SUBMIP_ENABLE_CPUFJ "mip_hyper_submip_enable_cpufj" +/* @brief Block bounded-variable-elimination step of cuOpt's internal MIP presolve */ +#define CUOPT_MIP_HYPER_BLOCK_BVE "mip_hyper_block_bve" + /* @brief QCQP (barrier) scaling hyper-parameters */ #define CUOPT_QCQP_HYPER_RUIZ_EQUILIBRATION "qcqp_hyper_ruiz_equilibration" diff --git a/cpp/include/cuopt/mathematical_optimization/mip/solver_settings.hpp b/cpp/include/cuopt/mathematical_optimization/mip/solver_settings.hpp index 8bb676cb94..4a363b1dbc 100644 --- a/cpp/include/cuopt/mathematical_optimization/mip/solver_settings.hpp +++ b/cpp/include/cuopt/mathematical_optimization/mip/solver_settings.hpp @@ -162,6 +162,15 @@ class mip_solver_settings_t { * When this is `false`, probing is skipped even if presolve is otherwise on. */ bool probing{true}; + /** + * @brief Enable the block bounded-variable-elimination step of cuOpt's MIP presolve. + * + * Runs after trivial_presolve and eliminates blocks of functionally-determined binary auxiliary + * variables discovered via the probing-cache implication closure, re-encoding each block's + * projected relation as certified prime-implicate clauses. Requires the probing-cache step; a + * no-op when no certified reduction exists. + */ + bool block_bve{true}; /** * @brief Determinism mode for MIP solver. * diff --git a/cpp/src/io/mps_writer.cpp b/cpp/src/io/mps_writer.cpp index 31a0bbc25c..bb769aceb1 100644 --- a/cpp/src/io/mps_writer.cpp +++ b/cpp/src/io/mps_writer.cpp @@ -229,8 +229,8 @@ void mps_writer_t::write(const std::string& mps_file_path) // save coefficients with full precision mps_file << std::setprecision(std::numeric_limits::max_digits10); - // NAME section - mps_file << "NAME " << problem_.get_problem_name() << "\n"; + const std::string& pname = problem_.get_problem_name(); + mps_file << "NAME " << (pname.empty() ? "cuopt" : pname) << "\n"; if (problem_.get_sense()) { mps_file << "OBJSENSE\n MAXIMIZE\n"; } diff --git a/cpp/src/math_optimization/solver_settings.cu b/cpp/src/math_optimization/solver_settings.cu index 2a193cd70b..44f8ac8246 100644 --- a/cpp/src/math_optimization/solver_settings.cu +++ b/cpp/src/math_optimization/solver_settings.cu @@ -213,6 +213,7 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings {CUOPT_MIP_HYPER_DIVING_SHOW_TYPE, &mip_settings.diving_params.show_type, false, "log diving heuristic type when it finds a new incumbent"}, // Recursive sub-MIP (RINS) hyper-parameters (hidden from default --help: name contains "hyper_") {CUOPT_MIP_HYPER_SUBMIP_ENABLE_CPUFJ, &mip_settings.submip_params.enable_cpufj, true, "run CPU FJ over the sub-MIP"}, + {CUOPT_MIP_HYPER_BLOCK_BVE, &mip_settings.block_bve, true, "eliminate blocks of binaries in cuOpt's MIP presolve (needs " CUOPT_MIP_PROBING ")"}, }; // String parameters string_parameters = { diff --git a/cpp/src/mip_heuristics/CMakeLists.txt b/cpp/src/mip_heuristics/CMakeLists.txt index 7705465512..6ad1009d84 100644 --- a/cpp/src/mip_heuristics/CMakeLists.txt +++ b/cpp/src/mip_heuristics/CMakeLists.txt @@ -33,6 +33,7 @@ set(MIP_NON_LP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/local_search/rounding/simple_rounding.cu ${CMAKE_CURRENT_SOURCE_DIR}/local_search/feasibility_pump/feasibility_pump.cu ${CMAKE_CURRENT_SOURCE_DIR}/local_search/line_segment_search/line_segment_search.cu + ${CMAKE_CURRENT_SOURCE_DIR}/presolve/block_bve.cu ${CMAKE_CURRENT_SOURCE_DIR}/presolve/bounds_presolve.cu ${CMAKE_CURRENT_SOURCE_DIR}/presolve/bounds_update_data.cu ${CMAKE_CURRENT_SOURCE_DIR}/presolve/semi_continuous.cu diff --git a/cpp/src/mip_heuristics/diversity/diversity_manager.cu b/cpp/src/mip_heuristics/diversity/diversity_manager.cu index 9d70ae17ee..ec82c4b423 100644 --- a/cpp/src/mip_heuristics/diversity/diversity_manager.cu +++ b/cpp/src/mip_heuristics/diversity/diversity_manager.cu @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include +#include #include #include @@ -307,7 +309,10 @@ bool diversity_manager_t::run_presolve(f_t time_limit, timer_t global_ CUOPT_LOG_INFO("Probing-cache step disabled via %s=false", CUOPT_MIP_PROBING); run_probing_cache = false; } - if (run_probing_cache) { + const bool remap_cache_ids = true; + problem_ptr->related_vars_time_limit = context.settings.heuristic_params.related_vars_time_limit; + + if (run_probing_cache && !global_timer.check_time_limit() && !presolve_timer.check_time_limit()) { log_presolve_budget("PROBING", probing_features, probing_budget); f_t time_for_probing_cache = std::min(time_limit, (f_t)global_timer.remaining_time()); timer_t probing_timer{time_for_probing_cache}; @@ -324,9 +329,17 @@ bool diversity_manager_t::run_presolve(f_t time_limit, timer_t global_ std::chrono::duration(std::chrono::steady_clock::now() - probing_t0).count()); if (problem_is_infeasible) { return false; } } - const bool remap_cache_ids = true; - problem_ptr->related_vars_time_limit = context.settings.heuristic_params.related_vars_time_limit; + if (!global_timer.check_time_limit()) { trivial_presolve(*problem_ptr, remap_cache_ids); } + + if (context.settings.block_bve && run_probing_cache) { + timer_t bve_deadline(std::min(global_timer.remaining_time(), presolve_timer.remaining_time())); + if (!block_bve_phase(ls.constraint_prop.bounds_update, *problem_ptr, bve_deadline)) { + stats.presolve_time = timer.elapsed_time(); + return false; + } + } + if (!problem_ptr->empty && !check_bounds_sanity(*problem_ptr)) { return false; } // if (!presolve_timer.check_time_limit() && !context.settings.heuristics_only && // !problem_ptr->empty) { diff --git a/cpp/src/mip_heuristics/diversity/population.cu b/cpp/src/mip_heuristics/diversity/population.cu index 553e5d6e93..e6fffa97b2 100644 --- a/cpp/src/mip_heuristics/diversity/population.cu +++ b/cpp/src/mip_heuristics/diversity/population.cu @@ -233,6 +233,12 @@ std::vector> population_t::get_external_solutions sol.compute_number_of_integers(), problem_ptr->n_integer_vars); } + if (std::abs(sol.get_objective() - h_entry.objective) > OBJECTIVE_EPSILON) { + CUOPT_LOG_DEBUG( + "External solution objective mismatch: sol.get_objective() = %g, h_entry.objective = %g", + sol.get_objective(), + h_entry.objective); + } sol.handle_ptr->sync_stream(); return_vector.emplace_back(std::move(sol)); counter++; diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu index 697f26e6df..463f074f59 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu @@ -12,7 +12,7 @@ #include #include -#include +#include #include @@ -63,7 +63,7 @@ void early_gpufj_t::start() #pragma omp task default(none) shared(fj_ptr_) priority(CUOPT_DEFAULT_TASK_PRIORITY) \ depend(out : *fj_ptr_) { - RAFT_CUDA_TRY(cudaSetDevice(this->device_id_)); + raft::device_setter guard(this->device_id_); fj_ptr_->solve(*this->solution_ptr_); } } diff --git a/cpp/src/mip_heuristics/presolve/block_bve.cu b/cpp/src/mip_heuristics/presolve/block_bve.cu new file mode 100644 index 0000000000..6874833e15 --- /dev/null +++ b/cpp/src/mip_heuristics/presolve/block_bve.cu @@ -0,0 +1,1553 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "block_bve.cuh" +#include "trivial_presolve.cuh" + +#include +#include +#include + +#include + +#include +#include + +#include + +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +static constexpr int BVE_MAX_INTERIOR = BVE_MAX_SCOPE - 1; +// Cap closure probes over high-degree implication neighborhoods. +static constexpr int BVE_MAX_GROWTH_NBRS = 256; +// Cap peak device allocation for each projection chunk. +static constexpr size_t BVE_PROJECT_DEVICE_BUDGET = 64ull << 20; // 64 MiB +// Cap the enumeration cost of one projection batch, summed as 2^(na+nb) * nnz over the candidates +// accepted into it. +static constexpr double BVE_BATCH_PROJECTION_BUDGET = 1e8; +static constexpr int BVE_MIN_COMMIT_RATIO = 20; +// Outer rounds of the phase: each re-derives the implication graph from the model the previous one +// left behind. +static constexpr int BVE_MAX_ROUNDS = 3; +// Share of the model's columns a round must retire for another round's detect pass to be worth +// running. +static constexpr double BVE_MIN_ROUND_YIELD = 0.01; +// Seconds for the whole phase: implication graph build plus every round. Install and compact finish +// the round already committed, so a phase can exceed this by that tail. +static constexpr double BVE_STAGE_TIME_LIMIT = 1.5; + +// Largest per-row rational multiplier / denominator we will apply. A row that would need a larger +// multiplier to become integer is treated as not exactly representable +static constexpr int64_t BVE_INT_SCALE_MAX = 1e6; + +// Closed-form part of the commit_projected work estimate: prime-cube enumeration in +// bve_greedy_prime_cover is Θ(nb · 3^nb); sanity check is Θ(2^nb · #clauses) with #clauses bounded +// by the growth gate (n_rows + clause_growth_margin). +static double bve_commit_wall_ops(int nb, int clause_budget) +{ + cuopt_assert(nb >= 0 && nb <= BVE_MAX_BOUNDARY, "nb out of BVE range"); + double three_nb = 1.0; + for (int i = 0; i < nb; ++i) + three_nb *= 3.0; + return nb * three_nb + (double)(1 << nb) * (clause_budget + 1); +} + +bool bve_sanity_check(const uint8_t* feas, int nb, const bve_clause_t* clauses, int n_clauses) +{ + const uint32_t full_mask = (1u << nb) - 1u; + for (int i = 0; i < n_clauses; ++i) + if (clauses[i].lit_mask & ~full_mask) return false; // literals must be on the boundary + for (uint32_t m = 0; m <= full_mask; ++m) { + bool crel = true; // CNF value: AND over clauses of (clause satisfied by pattern m) + for (int i = 0; i < n_clauses && crel; ++i) { + const uint32_t lit = clauses[i].lit_mask; + const uint32_t bit = clauses[i].bit_mask; + // clause satisfied iff some literal position differs from its forbidden bit under m + const bool satisfied = ((m ^ bit) & lit) != 0u; + if (!satisfied) crel = false; + } + const bool feasible = feas[m] != 0; + if (crel != feasible) return false; + } + return true; +} + +// =========================================================================================== +// Installed CNF: all prime forbidden cubes covered by max-gain greedy +// =========================================================================================== +// +// Two-level logic minimization in the shape of Quine, "The Problem of Simplifying Truth Functions" +// (Amer. Math. Monthly 1952) and McCluskey, "Minimization of Boolean Functions" (Bell System Tech. +// J. 1956): enumerate the prime implicants, then cover every minterm with a subset of them. Taking +// the primes of the infeasible patterns rather than the feasible ones makes each one a forbidden +// cube whose complement is a clause, so the cover comes out as a CNF instead of the usual DNF. +// +// The covering step is the greedy max-gain heuristic of Johnson, "Approximation Algorithms for +// Combinatorial Problems" (JCSS 1974), Lovász (Discrete Math. 1975) and Chvátal (Math. of OR 1979): +// repeatedly take the cube covering the most still-uncovered patterns, which lands within a factor +// 1 + ln m of the minimum cover for m infeasible patterns. + +static size_t bve_mask_words(int nb) { return ((1u << nb) + 63u) / 64u; } + +static int bve_mask_size(const bve_mask_t& m) +{ + int n = 0; + for (uint64_t w : m) + n += std::popcount(w); + return n; +} + +static void bve_mask_set(bve_mask_t& m, uint32_t pattern) +{ + cuopt_assert(size_t(pattern >> 6) < m.size(), "pattern outside mask width"); + m[pattern >> 6] |= uint64_t{1} << (pattern & 63); +} + +static void bve_mask_subtract(bve_mask_t& m, const bve_mask_t& other) +{ + cuopt_assert(m.size() == other.size(), "mask width mismatch"); + for (size_t w = 0; w < m.size(); ++w) + m[w] &= ~other[w]; +} + +static int bve_mask_overlap(const bve_mask_t& a, const bve_mask_t& b) +{ + cuopt_assert(a.size() == b.size(), "mask width mismatch"); + int n = 0; + for (size_t w = 0; w < a.size(); ++w) + n += std::popcount(a[w] & b[w]); + return n; +} + +// valid(lit, bit): every boundary pattern matching cube (lit, bit) is infeasible, so the +// complementary clause excludes no feasible pattern. Adding a literal SHRINKS the cube, so the +// table is filled from the minterms (lit == full_mask) downward in literal count: +// valid(lit, bit) = valid(lit|j, bit) AND valid(lit|j, bit|j) for any j not in lit +// A cube is already the bve_clause_t (lit_mask, bit_mask) encoding, so no separate ternary cube +// code is needed. The dense table is 4^nb bytes (16 MiB at nb = 12), so `valid` is caller-owned and +// grown once rather than reallocated per block. It is never re-initialized: only cells with +// bit subset of lit are ever addressed, the minterm seeding plus the recurrence below write every +// such cell, and each pass reads only cells an earlier pass already wrote. +static void bve_enumerate_prime_cubes(const uint8_t* feas, + int nb, + std::vector& valid, + std::vector& primes) +{ + cuopt_assert(nb >= 1 && nb <= BVE_MAX_BOUNDARY, "nb out of BVE range"); + const uint32_t full_mask = (1u << nb) - 1u; + const size_t stride = size_t(full_mask) + 1; + if (valid.size() < stride * stride) valid.resize(stride * stride); + const auto at = [&](uint32_t lit, uint32_t bit) -> uint8_t& { + cuopt_assert((bit & ~lit) == 0u, "cube bit_mask outside its lit_mask"); + return valid[size_t(lit) * stride + bit]; + }; + + for (uint32_t m = 0; m <= full_mask; ++m) + at(full_mask, m) = feas[m] ? 0 : 1; + + for (int n_lits = nb - 1; n_lits >= 0; --n_lits) + for (uint32_t lit = 0; lit <= full_mask; ++lit) { + if (std::popcount(lit) != n_lits) continue; + const int j = std::countr_zero(~lit & full_mask); + const uint32_t child = lit | (1u << j); + for (uint32_t bit = lit;; bit = (bit - 1u) & lit) { + at(lit, bit) = at(child, bit) & at(child, bit | (1u << j)); + if (bit == 0u) break; + } + } + + primes.clear(); + for (uint32_t lit = 0; lit <= full_mask; ++lit) + for (uint32_t bit = lit;; bit = (bit - 1u) & lit) { + if (at(lit, bit)) { + bool prime = true; + for (int j = 0; j < nb && prime; ++j) + if ((lit & (1u << j)) != 0u && at(lit ^ (1u << j), bit & ~(1u << j))) prime = false; + if (prime) primes.push_back(bve_clause_t{lit, bit}); + } + if (bit == 0u) break; + } +} + +// Boundary patterns matching the cube; every one of them is infeasible when the cube is valid. +static void bve_cube_cover( + uint32_t lit, uint32_t bit, uint32_t full_mask, size_t n_words, bve_mask_t& cover) +{ + cover.assign(n_words, 0u); + const uint32_t free_positions = full_mask & ~lit; + for (uint32_t s = free_positions;; s = (s - 1u) & free_positions) { + bve_mask_set(cover, bit | s); + if (s == 0u) break; + } +} + +int bve_greedy_prime_cover(const uint8_t* feas, + int nb, + bve_clause_t* out, + int cap, + bve_cover_scratch_t& scratch, + int64_t* ops_out) +{ + cuopt_assert(nb >= 1 && nb <= BVE_MAX_BOUNDARY, "nb out of BVE range"); + cuopt_assert(cap >= 1, "clause cap leaves no room for a cover"); + int64_t ops = 0; + auto ops_guard = cuopt::scope_guard([&]() { + if (ops_out != nullptr) *ops_out += ops; + }); + + const uint32_t full_mask = (1u << nb) - 1u; + const uint32_t n_patterns = 1u << nb; + const size_t n_words = bve_mask_words(nb); + + bve_enumerate_prime_cubes(feas, nb, scratch.valid, scratch.primes); + const std::vector& primes = scratch.primes; + + bve_mask_t& uncovered = scratch.uncovered; + uncovered.assign(n_words, 0u); + for (uint32_t m = 0; m < n_patterns; ++m) + if (!feas[m]) bve_mask_set(uncovered, m); + if (bve_mask_size(uncovered) == 0) return 0; // nothing to forbid + cuopt_assert(!primes.empty(), "infeasible patterns exist but no prime cube was enumerated"); + + scratch.cover.resize(primes.size()); + for (size_t q = 0; q < primes.size(); ++q) { + bve_cube_cover(primes[q].lit_mask, primes[q].bit_mask, full_mask, n_words, scratch.cover[q]); + // Zeroing the words, then one set-bit per pattern the cube matches. + ops += n_words + (int64_t{1} << (nb - std::popcount(primes[q].lit_mask))); + } + + int n = 0; + while (bve_mask_size(uncovered) > 0) { + // Per pick: the size test above, one bve_mask_overlap per prime, then the subtract below. + ops += (primes.size() + 2) * n_words; + int best_q = -1; + int best_gain = 0; + for (size_t q = 0; q < primes.size(); ++q) { + const int gain = bve_mask_overlap(uncovered, scratch.cover[q]); + if (gain > best_gain) { + best_gain = gain; + best_q = q; + } + } + cuopt_assert(best_q >= 0, "prime cubes do not cover the infeasible patterns"); + if (n >= cap) return -1; + out[n++] = primes[best_q]; + bve_mask_subtract(uncovered, scratch.cover[best_q]); + } + ops += n_words; // the size test that ended the loop + cuopt_assert(n >= 1, "non-empty infeasible set covered by zero clauses"); + return n; +} + +// Committed elimination in commit order. `witness[pattern]` packs interior values for the boundary +// pattern; reductions are replayed in reverse order during postsolve. +template +struct bve_reduction_t { + std::vector interior; + std::vector boundary; + std::vector witness; // size 2^boundary.size() +}; + +// A surviving clause row to append to problem_t (a set-covering no-good over boundary columns). +// Always in >= form. +template +struct bve_added_row_t { + std::vector> terms; + f_t lower; +}; + +template +struct bve_plan_t { + std::vector> reductions; // commit order + std::vector removed_rows; // original row ids to drop + std::vector> added_rows; // surviving clause rows +}; + +// Working model and accumulated reduction plan. Candidates are staged without mutation and +// committed only after projection and clause validation. +template +struct bve_reducer_t { + struct work_row_t { + std::vector> terms; + f_t lo, up; + bool active; + }; + + i_t n_vars, n_rows_orig; + f_t tol; + i_t boundary_cap, scope_cap, clause_growth_margin; + std::vector rows; + std::vector> col2rows; + std::vector is_bin, obj_nz, done; + bve_plan_t plan; + bve_cover_scratch_t cover_scratch; + + bve_reducer_t(i_t n_vars_, + i_t n_rows_orig_, + const std::vector& offsets, + const std::vector& variables, + const std::vector& coefficients, + const std::vector& row_lower, + const std::vector& row_upper, + const std::vector& col_lower, + const std::vector& col_upper, + const std::vector& is_integer, + const std::vector& obj, + f_t tol_, + i_t boundary_cap_, + i_t scope_cap_, + i_t clause_growth_margin_); + + // Rows spanned by `interior` and the boundary columns of those rows, both unsorted, with op + // accounting. Single traversal behind both the growth probe (which needs only the boundary size) + // and stage(); outputs are overwritten, so a caller in a loop can reuse them. + void scope_of(const std::vector& interior, + std::vector& rows_out, + std::vector& boundary_out, + int64_t& ops) const; + + // Gather and pack a candidate without projecting or mutating the working model. + bool stage(const std::vector& interior_in, + bve_candidate_t& out, + int64_t* ops_out = nullptr); + + // Validate and commit an already-projected candidate; return true iff reduced. + bool commit_projected(const bve_candidate_t& cand, int64_t* ops_out = nullptr); + + bve_plan_t finalize(); +}; + +template +bve_reducer_t::bve_reducer_t(i_t n_vars_, + i_t n_rows_orig_, + const std::vector& offsets, + const std::vector& variables, + const std::vector& coefficients, + const std::vector& row_lower, + const std::vector& row_upper, + const std::vector& col_lower, + const std::vector& col_upper, + const std::vector& is_integer, + const std::vector& obj, + f_t tol_, + i_t boundary_cap_, + i_t scope_cap_, + i_t clause_growth_margin_) + : n_vars(n_vars_), + n_rows_orig(n_rows_orig_), + tol(tol_), + boundary_cap(boundary_cap_), + scope_cap(scope_cap_), + clause_growth_margin(clause_growth_margin_), + col2rows(n_vars_), + is_bin(n_vars_), + obj_nz(n_vars_), + done(n_vars_, 0) +{ + const f_t INF = std::numeric_limits::infinity(); + for (i_t c = 0; c < n_vars; ++c) { + is_bin[c] = + (is_integer[c] && std::abs(col_lower[c]) < tol && std::abs(col_upper[c] - f_t(1)) < tol) ? 1 + : 0; + obj_nz[c] = (obj[c] != f_t(0)) ? 1 : 0; + } + rows.reserve(n_rows_orig * 2); + for (i_t r = 0; r < n_rows_orig; ++r) { + work_row_t R; + R.active = true; + R.lo = scaling_bound_finite(row_lower[r]) ? row_lower[r] : -INF; + R.up = scaling_bound_finite(row_upper[r]) ? row_upper[r] : INF; + for (i_t k = offsets[r]; k < offsets[r + 1]; ++k) + R.terms.emplace_back(variables[k], coefficients[k]); + i_t id = rows.size(); + rows.push_back(std::move(R)); + for (auto& p : rows[id].terms) + col2rows[p.first].insert(id); + } +} + +template +void bve_reducer_t::scope_of(const std::vector& interior, + std::vector& rows_out, + std::vector& boundary_out, + int64_t& ops) const +{ + ops += interior.size(); + std::unordered_set interior_set(interior.begin(), interior.end()); + std::unordered_set affected_rows; + for (i_t a : interior) + for (i_t r : col2rows[a]) { + ++ops; + affected_rows.insert(r); + } + std::unordered_set b; + for (i_t r : affected_rows) + for (const auto& p : rows[r].terms) { + ++ops; + if (!interior_set.count(p.first)) b.insert(p.first); + } + rows_out.assign(affected_rows.begin(), affected_rows.end()); + boundary_out.assign(b.begin(), b.end()); +} + +// Rescale every row to integer coefficients and bounds so the projection can run at tolerance 0. +// Returns false if any row does not scale to bounded integers. +template +static bool integerize_projection_rows(bve_block_t& block) +{ + for (int rr = 0; rr < block.n_rows; ++rr) { + const int rb = block.row_off[rr]; + const int re = block.row_off[rr + 1]; + const double s = row_int_scale(block.row_coef + rb, + re - rb, + block.row_lo[rr], + block.row_up[rr], + BVE_MAX_ROW_LEN, + BVE_INT_SCALE_MAX); + if (s == 0.0) return false; + for (int k = rb; k < re; ++k) + block.row_coef[k] = std::llround((double)block.row_coef[k] * s); + if (scaling_bound_finite(block.row_lo[rr])) + block.row_lo[rr] = std::llround((double)block.row_lo[rr] * s); + if (scaling_bound_finite(block.row_up[rr])) + block.row_up[rr] = std::llround((double)block.row_up[rr] * s); + } + return true; +} + +template +bool bve_reducer_t::stage(const std::vector& interior_in, + bve_candidate_t& out, + int64_t* ops_out) +{ + int64_t ops = 0; + auto ops_guard = cuopt::scope_guard([&]() { + if (ops_out != nullptr) *ops_out += ops; + }); + + std::vector interior(interior_in.begin(), interior_in.end()); + std::sort(interior.begin(), interior.end()); + std::vector affected_rows, boundary; + scope_of(interior, affected_rows, boundary, ops); + // sorting improves GPU shape-binning + std::sort(affected_rows.begin(), affected_rows.end()); + ops += affected_rows.size(); + std::sort(boundary.begin(), boundary.end()); + ops += boundary.size(); + + const i_t nb = boundary.size(); + const i_t na = interior.size(); + if (nb == 0 || nb > boundary_cap || na + nb > scope_cap) return false; + for (i_t v : boundary) + if (!is_bin[v]) return false; + if (na > BVE_MAX_INTERIOR || nb > BVE_MAX_BOUNDARY || na + nb > BVE_MAX_SCOPE) return false; + if (affected_rows.size() > BVE_MAX_ROWS) return false; + + bve_block_t& blk = out.blk; + blk.na = na; + blk.nb = nb; + blk.n_rows = affected_rows.size(); + std::unordered_map local; + for (i_t j = 0; j < na; ++j) + local[interior[j]] = j; + for (i_t j = 0; j < nb; ++j) + local[boundary[j]] = na + j; + ops += na + nb; + i_t nzc = 0; + bool row_overflow = false; + for (i_t rr = 0; rr < blk.n_rows && !row_overflow; ++rr) { + const i_t r = affected_rows[rr]; + blk.row_off[rr] = nzc; + if (rows[r].terms.size() > BVE_MAX_ROW_LEN || nzc + rows[r].terms.size() > BVE_MAX_NNZ) { + row_overflow = true; + break; + } + for (auto& p : rows[r].terms) { + blk.row_var[nzc] = local[p.first]; + blk.row_coef[nzc] = p.second; + ++nzc; + ++ops; + } + blk.row_lo[rr] = rows[r].lo; + blk.row_up[rr] = rows[r].up; + } + if (row_overflow) return false; + blk.row_off[blk.n_rows] = nzc; + + if (!integerize_projection_rows(blk)) return false; + + out.interior = std::move(interior); + out.boundary = std::move(boundary); + out.rows = std::move(affected_rows); + out.projection.feasible.assign(size_t(1) << nb, 0); + out.projection.witness.assign(size_t(1) << nb, 0u); + ops += 1 << nb; + return true; +} + +template +bool bve_reducer_t::commit_projected(const bve_candidate_t& cand, + int64_t* ops_out) +{ + const int nb = cand.blk.nb; + const uint8_t* feasible = cand.projection.feasible.data(); + cuopt_assert(cand.projection.feasible.size() == (size_t(1) << nb), "projection table unsized"); + bve_clause_t clauses[BVE_MAX_CLAUSES]; + const int n_clauses = + bve_greedy_prime_cover(feasible, nb, clauses, BVE_MAX_CLAUSES, cover_scratch, ops_out); + if (n_clauses < 0) return false; // clause explosion past cap + if (n_clauses > cand.blk.n_rows + clause_growth_margin) return false; // growth gate + if (!bve_sanity_check(feasible, nb, clauses, n_clauses)) + return false; // sanity check failed => keep block + + bve_reduction_t red; + red.interior = cand.interior; + red.boundary = cand.boundary; + red.witness = cand.projection.witness; + plan.reductions.push_back(std::move(red)); + + for (i_t r : cand.rows) { + for (auto& p : rows[r].terms) + col2rows[p.first].erase(r); + rows[r].active = false; + rows[r].terms.clear(); + } + const f_t INF = std::numeric_limits::infinity(); + for (i_t ci = 0; ci < n_clauses; ++ci) { + const uint32_t lit = clauses[ci].lit_mask; + const uint32_t bit = clauses[ci].bit_mask; + cuopt_assert(lit != 0u, "empty clause reached the row builder"); + work_row_t R; + R.active = true; + R.up = INF; + i_t n1 = 0; + for (i_t j = 0; j < nb; ++j) + if (lit & (1u << j)) { + const i_t b = (bit >> j) & 1u; + R.terms.emplace_back(cand.boundary[j], b ? f_t(-1) : f_t(1)); + n1 += b; + } + R.lo = f_t(1 - n1); + i_t id = rows.size(); + rows.push_back(std::move(R)); + for (auto& p : rows[id].terms) + col2rows[p.first].insert(id); + } + for (i_t a : cand.interior) { + col2rows[a].clear(); + done[a] = 1; + } + return true; +} + +template +bve_plan_t bve_reducer_t::finalize() +{ + for (i_t r = 0; r < n_rows_orig; ++r) + if (!rows[r].active) plan.removed_rows.push_back(r); + for (size_t r = n_rows_orig; r < rows.size(); ++r) + if (rows[r].active) { + cuopt_assert(rows[r].up == std::numeric_limits::infinity(), + "clause rows carry no upper bound"); + bve_added_row_t ar; + ar.terms = std::move(rows[r].terms); + ar.lower = rows[r].lo; + plan.added_rows.push_back(std::move(ar)); + } + return plan; +} + +// =========================================================================================== +// GPU enumeration projection kernel +// =========================================================================================== + +// +// grid : one CTA per assignment (block, boundary pattern m, interior pattern am), +// grid-strided over CTAs ( for assignment = blockIdx.x; ...; += gridDim.x ) +// CTA : one warp per row ( blockDim.x == min(nrows,32)*32; warps loop if nrows > 32 ) +// warp : reduces sum = Σ coeff * value over the row's entries, tests sum in [lower, upper] +// +// The CTA ANDs the per-row satisfied bits into a single "assignment feasible" bit. For each +// boundary pattern m, feasibility is the OR over its interior patterns am and the witness is the +// first feasible am; both are encoded by a single atomicMin into `out_witness` (sentinel 0xFFFFFFFF +// = no feasible interior), so downstream: +// feasible[block][m] == (out_witness[block][m] != 0xFFFFFFFF) +// witness [block][m] == out_witness[block][m] // the smallest feasible interior +// `out_witness` must be initialized to 0xFFFFFFFF by the caller before launch. + +template +__global__ void bve_enumerate_kernel( + i_t num_blocks, + i_t nb, + i_t na, + i_t nrows, + f_t tolerance, + const f_t* block_coeffs, // [num_blocks * nnz] + const i_t* local_var_of_entry, // [nnz] (shared by the bin) + const i_t* row_start, // [nrows + 1] (shared by the bin) + const f_t* block_row_lower, // [num_blocks * nrows] + const f_t* block_row_upper, // [num_blocks * nrows] + uint32_t* out_witness) // [num_blocks * (1<> (na + nb); + + const f_t* coeffs = block_coeffs + block * nnz; + const f_t* lower = block_row_lower + block * nrows; + const f_t* upper = block_row_upper + block * nrows; + + for (i_t row = warp_id; row < nrows; row += num_warps) { + f_t partial = 0; + for (i_t entry = row_start[row] + lane_id; entry < row_start[row + 1]; entry += 32) { + const i_t var = local_var_of_entry[entry]; + const f_t value = + (var < na) ? ((interior_pattern >> var) & 1) : ((boundary_pattern >> (var - na)) & 1); + partial += coeffs[entry] * value; + } + const f_t sum = raft::warpReduce(partial); + if (lane_id == 0) { + row_satisfied[row] = + (sum <= upper[row] + tolerance && sum >= lower[row] - tolerance) ? 1 : 0; + } + } + __syncthreads(); + + // AND the per-row bits; if this assignment is feasible, offer its interior as a witness + if (threadIdx.x == 0) { + uint8_t feasible = 1; + for (i_t row = 0; row < nrows; ++row) { + feasible &= row_satisfied[row]; + } + if (feasible) { + atomicMin(&out_witness[block * num_patterns + boundary_pattern], + (uint32_t)interior_pattern); + } + } + __syncthreads(); + } +} + +// ---- GPU batch projection: one enumeration-kernel launch per shape-bin ---- +// Returns raw work for the enumerations (sum over bins of assignments · nnz). +template +double bve_project_batch_gpu(const raft::handle_t& handle, + std::vector>& cands, + f_t tol, + const timer_t& timer) +{ + if (cands.empty()) return 0.0; + auto stream = handle.get_stream(); + double work_units = 0.0; + + // Bin candidates by identical shape so every CTA in a launch runs the same loop structure. + struct shape_key_hash { + size_t operator()(const std::vector& key) const + { + size_t h = 0; + for (i_t x : key) { + h ^= std::hash{}(x) + 0x9e3779b9 + (h << 6) + (h >> 2); + } + return h; + } + }; + std::unordered_map, std::vector, shape_key_hash> bins; + for (size_t i = 0; i < cands.size(); ++i) { + const auto& blk = cands[i].blk; + const i_t nnz = blk.row_off[blk.n_rows]; + std::vector key; + key.reserve(4 + (blk.n_rows + 1) + nnz); + key.push_back(blk.na); + key.push_back(blk.nb); + key.push_back(blk.n_rows); + key.push_back(nnz); + for (i_t r = 0; r <= blk.n_rows; ++r) + key.push_back(blk.row_off[r]); + for (i_t k = 0; k < nnz; ++k) + key.push_back(blk.row_var[k]); + bins[std::move(key)].push_back(i); + } + + for (const auto& kv : bins) { + if (timer.check_time_limit()) return work_units; + const std::vector& idxs = kv.second; + const auto& proto = cands[idxs[0]].blk; + const i_t na = proto.na; + const i_t nb = proto.nb; + const i_t nrows = proto.n_rows; + const i_t nnz = proto.row_off[nrows]; + const i_t patterns = i_t(1) << nb; + + // Shared layout is O(nnz) and identical for every candidate in the bin. + std::vector h_row_start(proto.row_off, proto.row_off + nrows + 1); + std::vector h_local_var(proto.row_var, proto.row_var + nnz); + rmm::device_uvector d_row_start(h_row_start.size(), stream); + rmm::device_uvector d_local_var(h_local_var.size(), stream); + raft::copy(d_row_start.data(), h_row_start.data(), h_row_start.size(), stream); + raft::copy(d_local_var.data(), h_local_var.data(), h_local_var.size(), stream); + + // Per-block device cost: coeffs + row bounds + witness table. + const size_t bytes_per_block = size_t(nnz) * sizeof(f_t) + 2 * size_t(nrows) * sizeof(f_t) + + size_t(patterns) * sizeof(uint32_t); + + const size_t chunk = + std::max(1, + std::min(size_t(std::numeric_limits::max()), + BVE_PROJECT_DEVICE_BUDGET / std::max(1, bytes_per_block))); + + const int num_warps = std::min(nrows, 32); + const int cta_dim = num_warps * 32; + const size_t shmem = size_t(nrows) * sizeof(uint8_t); + + for (size_t offset = 0; offset < idxs.size(); offset += chunk) { + if (timer.check_time_limit()) return work_units; + const size_t num_sz = std::min(chunk, idxs.size() - offset); + const i_t num = num_sz; + + std::vector h_coeffs(num_sz * size_t(nnz)); + std::vector h_lower(num_sz * size_t(nrows)); + std::vector h_upper(num_sz * size_t(nrows)); + for (size_t g = 0; g < num_sz; ++g) { + const auto& blk = cands[idxs[offset + g]].blk; + std::copy(blk.row_coef, blk.row_coef + nnz, h_coeffs.begin() + g * nnz); + std::copy(blk.row_lo, blk.row_lo + nrows, h_lower.begin() + g * nrows); + std::copy(blk.row_up, blk.row_up + nrows, h_upper.begin() + g * nrows); + } + + rmm::device_uvector d_coeffs(h_coeffs.size(), stream); + rmm::device_uvector d_lower(h_lower.size(), stream); + rmm::device_uvector d_upper(h_upper.size(), stream); + rmm::device_uvector d_witness(num_sz * size_t(patterns), stream); + raft::copy(d_coeffs.data(), h_coeffs.data(), h_coeffs.size(), stream); + raft::copy(d_lower.data(), h_lower.data(), h_lower.size(), stream); + raft::copy(d_upper.data(), h_upper.data(), h_upper.size(), stream); + // sentinel 0xFFFFFFFF (every byte 0xFF) marks a boundary pattern with no feasible interior + // yet + RAFT_CUDA_TRY( + cudaMemsetAsync(d_witness.data(), 0xFF, d_witness.size() * sizeof(uint32_t), stream)); + + // one warp per row, one CTA per (block, m, am) assignment, grid-strided + const int64_t total = (int64_t)num * (int64_t)patterns * ((int64_t)1 << na); + const int grid = std::min(total, int64_t{65535}); + bve_enumerate_kernel<<>>(num, + nb, + na, + nrows, + tol, + d_coeffs.data(), + d_local_var.data(), + d_row_start.data(), + d_lower.data(), + d_upper.data(), + d_witness.data()); + RAFT_CUDA_TRY(cudaGetLastError()); + + // Unscaled op counts: host pack/unpack touches + one coeff read per assignment. + work_units += (double)num_sz * (nnz + 2 * nrows + patterns); + work_units += (double)total * nnz; + + std::vector h_witness(num_sz * size_t(patterns)); + raft::copy(h_witness.data(), d_witness.data(), h_witness.size(), stream); + handle.sync_stream(); + for (size_t g = 0; g < num_sz; ++g) { + auto& cand = cands[idxs[offset + g]]; + // No-op for anything stage() produced; sizes a caller that assembled `blk` by hand. + cand.projection.feasible.resize(patterns); + cand.projection.witness.resize(patterns); + for (i_t m = 0; m < patterns; ++m) { + const uint32_t w = h_witness[g * patterns + m]; + const bool feasible = (w != 0xFFFFFFFFu); + cand.projection.feasible[m] = feasible ? 1 : 0; + cand.projection.witness[m] = feasible ? w : 0u; + } + cand.projection.projected = true; + } + } + } + return work_units; +} + +// ---- harvest unary-conditioned implications from an exactly projected block ---- +// +// `feas` is the block's exact existential projection onto its nb boundary columns, so for boundary +// position j and value a the feasible patterns agreeing with (j == a) describe every completion the +// block admits. Intersecting them (AND) gives the positions forced to 1 and the complement of their +// union (OR) gives those forced to 0; the same reasoning with no condition gives unconditional +// fixings. This is complete for the block's rows, where the probing cache only holds what bound +// propagation could prove, so these forcings can be strictly stronger. It holds whether or not the +// block is eventually eliminated, hence the call site harvests before the growth gate can reject. +// +// Ids are emitted in the current-problem frame; the caller maps them to original ids. +template +static void bve_extract_forcings(const bve_candidate_t& cand, probe_findings_t& out) +{ + const i_t nb = cand.blk.nb; + cuopt_assert(nb > 0 && nb <= BVE_MAX_BOUNDARY, "boundary width out of range"); + cuopt_assert((i_t)cand.boundary.size() == nb, "boundary id count disagrees with block width"); + const uint32_t n_patterns = 1u << nb; + + // Accumulators for condition s = 2*j + a; slot 2*nb holds the unconditional case. + constexpr i_t n_slots = 2 * BVE_MAX_BOUNDARY + 1; + const i_t unconditional = 2 * nb; + const uint32_t all_ones = n_patterns - 1u; + uint32_t and_acc[n_slots]; + uint32_t or_acc[n_slots]; + std::fill_n(and_acc, unconditional + 1, all_ones); + std::fill_n(or_acc, unconditional + 1, 0u); + + uint32_t n_feasible = 0; + for (uint32_t m = 0; m < n_patterns; ++m) { + if (!cand.projection.feasible[m]) continue; + ++n_feasible; + and_acc[unconditional] &= m; + or_acc[unconditional] |= m; + for (i_t j = 0; j < nb; ++j) { + const i_t s = 2 * j + ((m >> j) & 1u); + and_acc[s] &= m; + or_acc[s] |= m; + } + } + // Vacuous accumulators would otherwise read as "every position forced to 1". + if (n_feasible == 0u) return; // the caller turns this block into an infeasibility proof + + // Positions the block fixes outright. + const uint32_t fixed_mask = and_acc[unconditional] | (~or_acc[unconditional] & all_ones); + for (i_t j = 0; j < nb; ++j) { + if (!(fixed_mask & (1u << j))) continue; + out.fixings.emplace_back(cand.boundary[j], ((and_acc[unconditional] >> j) & 1u) != 0u); + } + + for (i_t j = 0; j < nb; ++j) { + if (fixed_mask & (1u << j)) continue; // condition never binds + for (i_t a = 0; a < 2; ++a) { + const i_t s = 2 * j + a; + for (i_t k = 0; k < nb; ++k) { + const uint32_t bit = 1u << k; + if (k == j || (fixed_mask & bit)) continue; + if (and_acc[s] & bit) { + out.forcings.push_back({cand.boundary[j], cand.boundary[k], a != 0, true}); + } else if (!(or_acc[s] & bit)) { + out.forcings.push_back({cand.boundary[j], cand.boundary[k], a != 0, false}); + } + } + } + } +} + +template +struct bve_growth_result_t { + std::vector interior; // sorted current-problem column ids, always contains the seed + int64_t ops = 0; // work performed, for the deterministic wall estimate +}; + +// Grows one seed into a block interior: starting from {seed}, repeatedly absorb the eligible +// implication-neighbor that shrinks the boundary the most, stopping when no neighbor strictly +// improves it or a cap is hit. Read-only on `reducer`, which is what lets the round run this across +// seeds under OpenMP against a frozen model. +template +static bve_growth_result_t grow_seed_interior( + i_t seed, + const bve_reducer_t& reducer, + const std::vector>& implication_adjacency, + const timer_t& timer) +{ + auto has_adj = [&](i_t v) { + return v >= 0 && v < (i_t)implication_adjacency.size() && !implication_adjacency[v].empty(); + }; + + bve_growth_result_t result; + std::unordered_set interior_set = {seed}; + std::vector probe_rows, probe_bnd; // scope_of scratch, reused across probes + bool timed_out = false; + for (;;) { + if (timer.check_time_limit()) break; + // Hub fast-path: raw implication degree upper-bounds |cands_w|. + if (interior_set.size() == 1) { + const i_t s = *interior_set.begin(); + const i_t deg = has_adj(s) ? (i_t)implication_adjacency[s].size() : 0; + if (deg > BVE_MAX_GROWTH_NBRS) break; + } + std::vector candidate_interior(interior_set.begin(), interior_set.end()); + reducer.scope_of(candidate_interior, probe_rows, probe_bnd, result.ops); + const i_t cur = probe_bnd.size(); + // Implication-neighbors of the interior that are still eligible to enter it. + std::unordered_set cands_w; + bool gated = false; + for (i_t a : interior_set) { + if (!has_adj(a)) continue; + for (i_t w : implication_adjacency[a]) { + ++result.ops; + const bool eligible = reducer.is_bin[w] && !reducer.obj_nz[w] && !reducer.done[w] && + !reducer.col2rows[w].empty(); + if (interior_set.count(w) || !eligible) continue; + cands_w.insert(w); + if ((i_t)cands_w.size() > BVE_MAX_GROWTH_NBRS) { + gated = true; + break; + } + } + if (gated) break; + } + // Hub neighborhoods: full probe is Θ(|cands_w|) boundary walks and rarely absorbs. + if (gated) break; + // Pick the neighbor with the smallest boundary + i_t best = -1; + i_t best_nb = cur; + i_t probes = 0; + for (i_t w : cands_w) { + // Each probe is a boundary walk, so the deadline is honoured within a single seed's growth. + if ((++probes & 0xF) == 0 && timer.check_time_limit()) { + timed_out = true; + break; + } + candidate_interior.push_back(w); // probe interior ∪ {w}; the pop below restores it + const i_t na = candidate_interior.size(); + reducer.scope_of(candidate_interior, probe_rows, probe_bnd, result.ops); + const i_t nb = probe_bnd.size(); + candidate_interior.pop_back(); + if (nb < best_nb && na + nb <= reducer.scope_cap && na <= BVE_MAX_INTERIOR) { + best_nb = nb; + best = w; + } + } + if (timed_out || best < 0) break; + interior_set.insert(best); + } + result.interior.assign(interior_set.begin(), interior_set.end()); + return result; +} + +// Implication-closure block growth over the probing-cache adjacency: each seed absorbs the +// implication-neighbor that most shrinks its boundary (subject to enum/interior caps) until no +// such neighbor remains. Within a round the working model is frozen, so every seed grows its +// interior against the same model. Because that growth is read-only on the model, it runs in an +// OpenMP parallel-for across the round's seeds; the results are deterministic per seed and +// acceptance is then applied serially in seed order, so the committed plan is identical to a serial +// run of the same frozen growth. Candidates are staged and only mutually scope-disjoint ones (no +// shared interior or boundary column, which also forbids a shared row) are accepted into the batch. +// The batch is projected on the device (bve_project_batch_gpu), then committed on the host; because +// the accepted candidates touch disjoint columns/rows, commit order is irrelevant and each block's +// staged projection is still valid at commit time. Candidates deferred for overlap are retried in +// later rounds; the loop stops when a round accepts nothing or commits nothing (each committing +// round retires >= 1 column, hence terminates). +// +// A block whose projection admits no boundary assignment sets `out_infeasible` and abandons the +// round with an empty plan, so nothing this call staged is ever installed. +template +static bve_plan_t bve_detect_closure_batched( + const raft::handle_t& handle, + bve_reducer_t& reducer, + const std::vector>& impl_adj, + timer_t& timer, + double& work_units, + probe_findings_t* findings, + bool* out_infeasible) +{ + std::vector order; + for (i_t c = 0; c < reducer.n_vars; ++c) { + // grow_seed_interior's hub fast path refuses to grow a seed whose implication degree is past + // the probe cap, so such a seed only ever reaches stage() as a singleton interior. + const i_t degree = c < (i_t)impl_adj.size() ? (i_t)impl_adj[c].size() : 0; + const bool growable = degree > 0 && degree <= BVE_MAX_GROWTH_NBRS; + if (reducer.is_bin[c] && !reducer.obj_nz[c] && !reducer.col2rows[c].empty() && growable) + order.push_back(c); + } + std::sort(order.begin(), order.end(), [&](i_t a, i_t b) { + return reducer.col2rows[a].size() < reducer.col2rows[b].size(); + }); + + std::vector attempted(reducer.n_vars, 0); + std::vector growth_done(reducer.n_vars, 0); + std::vector> growth_interior(reducer.n_vars); + for (;;) { + if (timer.check_time_limit()) break; + + // This round's live seeds, in the deterministic growth order. + std::vector round_seeds; + for (i_t seed : order) + if (!attempted[seed] && !reducer.done[seed] && !reducer.col2rows[seed].empty()) + round_seeds.push_back(seed); + if (round_seeds.empty()) break; + + // Grow each seed against the frozen model (read-only on reducer → OMP-safe). Acceptance below + // is serial in round_seeds order, so the plan matches a serial frozen-growth run. + std::vector> interiors(round_seeds.size()); + std::vector growth_ops(round_seeds.size(), 0); +#pragma omp taskloop default(shared) priority(CUOPT_DEFAULT_TASK_PRIORITY) + for (i_t k = 0; k < (i_t)round_seeds.size(); ++k) { + const i_t seed = round_seeds[k]; + if (growth_done[seed]) { + interiors[k] = growth_interior[seed]; + continue; + } + bve_growth_result_t grown = grow_seed_interior(seed, reducer, impl_adj, timer); + growth_ops[k] = grown.ops; + interiors[k] = std::move(grown.interior); + growth_interior[seed] = interiors[k]; + growth_done[seed] = 1; + } + // OMP growth: wall ≈ critical-path seed (max), not sum across threads. + int64_t max_growth_ops = 0; + for (int64_t ops : growth_ops) + max_growth_ops = std::max(max_growth_ops, ops); + work_units += max_growth_ops; + + if (timer.check_time_limit()) break; + + // Serial: stage each grown interior and greedily accept mutually SCOPE-DISJOINT candidates, in + // round_seeds order. Nothing mutates the model until commit, so this stays serial. + std::vector> cands; + std::unordered_set claimed; // interior+boundary columns of already-accepted candidates + double batch_projection_ops = 0.0; + for (size_t k = 0; k < round_seeds.size(); ++k) { + if (timer.check_time_limit()) break; + const i_t seed = round_seeds[k]; + bve_candidate_t cand; + int64_t stage_ops = 0; + if (!reducer.stage(interiors[k], cand, &stage_ops)) { + work_units += stage_ops; + attempted[seed] = 1; // failed the caps against this model; one attempt per seed + continue; + } + work_units += stage_ops; + bool overlap = false; + for (i_t c : cand.interior) + if (claimed.count(c)) { + overlap = true; + break; + } + if (!overlap) + for (i_t c : cand.boundary) + if (claimed.count(c)) { + overlap = true; + break; + } + if (overlap) continue; // scope collides; retry stage later from cached interior + + attempted[seed] = 1; + for (i_t c : cand.interior) + claimed.insert(c); + for (i_t c : cand.boundary) + claimed.insert(c); + cuopt_assert(cand.blk.na + cand.blk.nb <= BVE_MAX_SCOPE, "staged scope past enumeration cap"); + batch_projection_ops += + (double)(1u << (cand.blk.na + cand.blk.nb)) * cand.blk.row_off[cand.blk.n_rows]; + cands.push_back(std::move(cand)); + + if (batch_projection_ops >= BVE_BATCH_PROJECTION_BUDGET) break; + } + + if (cands.empty() || timer.check_time_limit()) break; + // Staged blocks are integerized (integerize_projection_rows), so the subset-sum feasibility + // test is exact at tolerance 0. + work_units += bve_project_batch_gpu(handle, cands, f_t(0), timer); + if (timer.check_time_limit()) break; + i_t committed = 0; + for (auto& cand : cands) { + if (timer.check_time_limit()) break; + cuopt_assert(cand.projection.projected, "commit loop reached an unprojected candidate"); + const bool admits_nothing = + cand.projection.projected && std::none_of(cand.projection.feasible.begin(), + cand.projection.feasible.end(), + [](uint8_t feasible) { return feasible != 0; }); + if (admits_nothing) { + if (out_infeasible != nullptr) *out_infeasible = true; + return {}; + } + // Valid for the block's rows regardless of the clause gates below, so harvest before them. + if (findings != nullptr) { + bve_extract_forcings(cand, *findings); + work_units += (double)(1u << cand.blk.nb) * cand.blk.nb; + } + work_units += + bve_commit_wall_ops(cand.blk.nb, cand.blk.n_rows + reducer.clause_growth_margin); + int64_t commit_ops = 0; + if (reducer.commit_projected(cand, &commit_ops)) ++committed; + work_units += commit_ops; + } + if (committed == 0) break; + cuopt_assert(committed <= (i_t)cands.size(), "committed more candidates than were projected"); + if ((i_t)cands.size() > committed * BVE_MIN_COMMIT_RATIO) break; + } + return reducer.finalize(); +} + +// ---- implication adjacency from the probing cache (original-id -> current column) ---- +template +std::vector> bve_build_impl_adj( + const probing_cache_t& cache, + const std::vector& reverse_original_ids, + i_t n_vars, + const timer_t& timer, + const probe_findings_t* prior_original_id_findings) +{ + // original-id -> current column index (or -1 if the column no longer exists) + auto to_current = [&](i_t original_id) -> i_t { + if (original_id < 0 || original_id >= (i_t)reverse_original_ids.size()) return -1; + return reverse_original_ids[original_id]; + }; + std::vector> adj(n_vars); + auto add_edge = [&](i_t original_x, i_t original_y) { + const i_t x = to_current(original_x); + if (x < 0 || x >= n_vars) return; + const i_t y = to_current(original_y); + if (y < 0 || y >= n_vars || y == x) return; + adj[x].insert(y); + adj[y].insert(x); + }; + // An abandoned build returns no edges rather than a partial graph, so which reductions exist + // never depends on where the clock landed. + i_t entries_seen = 0; + for (const auto& kv : cache.probing_cache) { + if ((++entries_seen & 0x3F) == 0 && timer.check_time_limit()) { + return std::vector>(n_vars); + } + for (int p = 0; p < 2; ++p) { + for (const auto& yb : kv.second[p].var_to_cached_bound_map) + add_edge(kv.first, yb.first); + } + } + // Forcings mined from earlier projections. Pairs the cache never held become seed/absorb + // candidates, so a later round can grow blocks the first round could not see. + if (prior_original_id_findings != nullptr) { + i_t forcings_seen = 0; + for (const auto& forcing : prior_original_id_findings->forcings) { + if ((++forcings_seen & 0x3FF) == 0 && timer.check_time_limit()) { + return std::vector>(n_vars); + } + add_edge(forcing.var, forcing.forced_var); + } + } + std::vector> out(n_vars); + for (i_t v = 0; v < n_vars; ++v) + out[v].assign(adj[v].begin(), adj[v].end()); + return out; +} + +template +static void append_bve_reconstructions(const bve_plan_t& plan, + const std::vector& current_to_post_papilo, + presolve_data_t& presolve_data, + double& work_units) +{ + auto to_post_papilo = [&](i_t column) { + cuopt_assert(column >= 0 && column < (i_t)current_to_post_papilo.size(), + "block column out of variable_mapping range"); + return current_to_post_papilo[column]; + }; + + auto& reconstructions = presolve_data.postsolve_reconstructions; + reconstructions.reserve(reconstructions.size() + plan.reductions.size()); + for (const auto& red : plan.reductions) { + work_units += red.interior.size() + red.boundary.size() + red.witness.size(); + postsolve_reconstruction_t reconstruction; + reconstruction.kind = reconstruction_kind_t::BlockBve; + reconstruction.bve.interior.reserve(red.interior.size()); + for (i_t c : red.interior) + reconstruction.bve.interior.push_back(to_post_papilo(c)); + reconstruction.bve.boundary.reserve(red.boundary.size()); + for (i_t c : red.boundary) + reconstruction.bve.boundary.push_back(to_post_papilo(c)); + reconstruction.bve.witness = red.witness; + reconstructions.push_back(std::move(reconstruction)); + } +} + +template +bool bve_has_stageable_row(const problem_t& problem) +{ + const i_t n_rows = problem.n_constraints; + if (problem.empty || n_rows == 0) { return false; } + const i_t* offsets = problem.offsets.data(); + constexpr i_t max_len = BVE_MAX_ROW_LEN; + return thrust::any_of(problem.handle_ptr->get_thrust_policy(), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(n_rows), + [offsets, max_len] __device__(i_t r) -> bool { + return offsets[r + 1] - offsets[r] <= max_len; + }); +} + +// Pin variables that a BVE projection table showed to have a single admissible value. Ids arrive in +// the original frame and may repeat across blocks and rounds. Returns false when two blocks +// disagree on a variable, which proves infeasibility since each fixing is a consequence of its +// block alone. +template +static bool apply_bve_fixings(problem_t& problem, + const std::vector>& fixings, + i_t& n_applied) +{ + n_applied = 0; + if (fixings.empty()) { return true; } + std::vector> sorted(fixings); + std::sort(sorted.begin(), sorted.end()); + + const std::vector& reverse_original_ids = problem.reverse_original_ids; + std::vector var_indices; + std::vector lb_values; + std::vector ub_values; + for (size_t k = 0; k < sorted.size(); ++k) { + const auto [original_id, value] = sorted[k]; + if (k > 0 && original_id == sorted[k - 1].first) { + if (value != sorted[k - 1].second) { return false; } + continue; + } + cuopt_assert(original_id >= 0 && original_id < (i_t)reverse_original_ids.size(), + "fixings are keyed by original id"); + const i_t column = reverse_original_ids[original_id]; + if (column < 0 || column >= problem.n_variables) { continue; } // already eliminated + var_indices.push_back(column); + lb_values.push_back(value ? f_t(1) : f_t(0)); + ub_values.push_back(value ? f_t(1) : f_t(0)); + } + n_applied = var_indices.size(); + problem.update_variable_bounds(var_indices, lb_values, ub_values); + return true; +} + +// ---- the pass: detect (GPU-projected) -> install reduced model -> record reconstructions ---- +template +bool block_bve_presolve(problem_t& problem, + const std::vector>& impl_adj, + timer_t& timer, + double& work_units, + probe_findings_t* out_findings, + bool* out_infeasible, + i_t boundary_cap, + i_t scope_cap, + i_t clause_growth_margin) +{ + work_units = 0.0; + timer_t wall(std::numeric_limits::infinity()); + [[maybe_unused]] double t_setup = 0.0, t_detect = 0.0, t_install = 0.0, t_compact = 0.0; + auto timer_raii_guard = cuopt::scope_guard([&]() { + CUOPT_LOG_DEBUG( + "Block-BVE phases: setup=%.2fs detect=%.2fs install=%.2fs compact=%.2fs total=%.2fs " + "work units: %.6g", + t_setup, + t_detect, + t_install, + t_compact, + wall.elapsed_time(), + work_units); + }); + + const raft::handle_t* handle = problem.handle_ptr; + auto stream = handle->get_stream(); + const i_t n_vars = problem.n_variables; + const i_t n_rows = problem.n_constraints; + const f_t tol = problem.tolerances.presolve_absolute_tolerance; + if (problem.empty || n_vars == 0 || n_rows == 0) return false; + + auto h_off = cuopt::host_copy(problem.offsets, stream); + auto h_var = cuopt::host_copy(problem.variables, stream); + auto h_coef = cuopt::host_copy(problem.coefficients, stream); + auto h_clb = cuopt::host_copy(problem.constraint_lower_bounds, stream); + auto h_cub = cuopt::host_copy(problem.constraint_upper_bounds, stream); + auto h_vb = cuopt::host_copy(problem.variable_bounds, stream); + auto h_vtype = cuopt::host_copy(problem.variable_types, stream); + auto h_obj = cuopt::host_copy(problem.objective_coefficients, stream); + auto h_vmap = cuopt::host_copy(problem.presolve_data.variable_mapping, stream); + handle->sync_stream(); + + const i_t nnz0 = h_off.back(); + work_units = 2.0 * nnz0 + 2.0 * n_vars + n_rows; + + if (timer.check_time_limit()) return false; + + std::vector offsets(h_off.begin(), h_off.end()); + std::vector variables(h_var.begin(), h_var.end()); + std::vector coefficients(h_coef.begin(), h_coef.end()); + std::vector row_lower(h_clb.begin(), h_clb.end()); + std::vector row_upper(h_cub.begin(), h_cub.end()); + std::vector col_lower(n_vars), col_upper(n_vars); + std::vector is_integer(n_vars); + for (i_t c = 0; c < n_vars; ++c) { + col_lower[c] = get_lower(h_vb[c]); + col_upper[c] = get_upper(h_vb[c]); + is_integer[c] = (h_vtype[c] == var_t::INTEGER) ? 1 : 0; + } + std::vector obj(h_obj.begin(), h_obj.end()); + + if (timer.check_time_limit()) return false; // the reducer below walks the CSR again + + // ---- detect + sanity check (probing-cache implication closure). Projection of each candidate + // block runs on the GPU: the batched detector stages scope-disjoint candidates per round and + // hands the whole batch to bve_project_batch_gpu (one enumeration-kernel launch per shape-bin), + // which fills feas/witness; commit (prime-implicate CNF + inline sanity check) then runs on the + // host. ---- + bve_reducer_t reducer(n_vars, + n_rows, + offsets, + variables, + coefficients, + row_lower, + row_upper, + col_lower, + col_upper, + is_integer, + obj, + tol, + boundary_cap, + scope_cap, + clause_growth_margin); + t_setup = wall.elapsed_time(); + probe_findings_t current_id_findings; + bool detected_infeasible = false; + bve_plan_t plan = + bve_detect_closure_batched(*handle, + reducer, + impl_adj, + timer, + work_units, + out_findings != nullptr ? ¤t_id_findings : nullptr, + &detected_infeasible); + t_detect = wall.elapsed_time() - t_setup; + + if (detected_infeasible) { + cuopt_assert(plan.reductions.empty(), "an infeasibility proof must abandon the round's plan"); + if (out_infeasible != nullptr) *out_infeasible = true; + return false; + } + + // Projection findings hold for the block's rows whether or not the block was eliminated, so they + // are exported before the no-reduction exit; the rejected blocks are often the interesting ones. + if (out_findings != nullptr) { + auto to_original = [&](i_t column) { + cuopt_assert(column >= 0 && column < (i_t)h_vmap.size(), "column outside variable_mapping"); + return (i_t)h_vmap[column]; + }; + out_findings->forcings.reserve(out_findings->forcings.size() + + current_id_findings.forcings.size()); + for (const auto& forcing : current_id_findings.forcings) { + out_findings->forcings.push_back({to_original(forcing.var), + to_original(forcing.forced_var), + forcing.value, + forcing.forced_value}); + } + for (const auto& [column, value] : current_id_findings.fixings) + out_findings->fixings.emplace_back(to_original(column), value); + } + + if (plan.reductions.empty()) return false; + + // ---- build the reduced forward CSR, append clause rows ---- + const double t_install_begin = wall.elapsed_time(); + std::vector removed(n_rows, 0); + for (i_t r : plan.removed_rows) + removed[r] = 1; + std::vector new_off, new_var; + std::vector new_coef, new_clb, new_cub; + new_off.reserve(n_rows + plan.added_rows.size() + 1); + new_off.push_back(0); + for (i_t r = 0; r < n_rows; ++r) { + if (removed[r]) continue; + for (i_t k = offsets[r]; k < offsets[r + 1]; ++k) { + new_var.push_back(variables[k]); + new_coef.push_back(coefficients[k]); + } + new_off.push_back(new_var.size()); + new_clb.push_back(row_lower[r]); + new_cub.push_back(row_upper[r]); + } + for (const auto& ar : plan.added_rows) { + cuopt_assert(!ar.terms.empty(), "installing a term-free row loses whatever it constrained"); + for (const auto& [var, coef] : ar.terms) { + new_var.push_back(var); + new_coef.push_back(coef); + } + new_off.push_back(new_var.size()); + new_clb.push_back(ar.lower); // eliminated interior cols become empty (only in removed rows) + // clause rows are >= no-goods; upper is +inf (problem_t convention) + new_cub.push_back(std::numeric_limits::infinity()); + } + + work_units += new_var.size() + new_clb.size(); + problem.set_constraints_from_host_csr(new_off, new_var, new_coef, new_clb, new_cub, {}); + + append_bve_reconstructions(plan, h_vmap, problem.presolve_data, work_units); + t_install = wall.elapsed_time() - t_install_begin; + + const double t_compact_begin = wall.elapsed_time(); + work_units += n_vars + new_var.size(); + trivial_presolve(problem, /*remap_cache_ids=*/true); + handle->sync_stream(); + t_compact = wall.elapsed_time() - t_compact_begin; + const i_t reduced_cols = n_vars - problem.n_variables; + const i_t reduced_rows = n_rows - problem.n_constraints; + if (reduced_cols > 0 || reduced_rows > 0) { + CUOPT_LOG_DEBUG("Block-BVE reduced %d columns, %d rows", reduced_cols, reduced_rows); + } +#if (CUOPT_LOG_ACTIVE_LEVEL <= RAPIDS_LOGGER_LOG_LEVEL_DEBUG) + const i_t fractional_coefs = + thrust::count_if(handle->get_thrust_policy(), + problem.coefficients.begin(), + problem.coefficients.end(), + [] __device__(f_t v) -> bool { return floor(v) != v; }); + CUOPT_LOG_DEBUG("Block-BVE: %d fractional coefficients in A", fractional_coefs); +#endif + return true; +} + +template +bool block_bve_phase(bound_presolve_t& bound_presolve, + problem_t& problem, + const timer_t& deadline) +{ + if (const char* disabled = std::getenv("CUOPT_DISABLE_BLOCK_BVE"); + disabled != nullptr && std::atoi(disabled) != 0) { + CUOPT_LOG_DEBUG("Block-BVE disabled via CUOPT_DISABLE_BLOCK_BVE"); + return true; + } + + if (bound_presolve.probing_cache.probing_cache.empty()) { + CUOPT_LOG_DEBUG("Block-BVE skipped: the probing cache is empty"); + return true; + } + + const i_t n_vars_before_phase = problem.n_variables; + const i_t n_rows_before_phase = problem.n_constraints; + + // Implications read off the projection tables, accumulated across rounds. They feed the next + // round's adjacency (pairs the cache never held) and are folded back into the cache afterwards. + probe_findings_t findings; + timer_t stage_timer(deadline.clamp_remaining_time(BVE_STAGE_TIME_LIMIT)); + for (i_t round = 0; round < BVE_MAX_ROUNDS; ++round) { + if (problem.empty || deadline.check_time_limit() || stage_timer.check_time_limit()) { break; } + + if (!bve_has_stageable_row(problem)) { + CUOPT_LOG_DEBUG("Block-BVE skipped: every row exceeds the %d-nonzero block row cap", + BVE_MAX_ROW_LEN); + break; + } + + const i_t n_vars_before = problem.n_variables; + const i_t n_rows_before = problem.n_constraints; + auto impl_adj = bve_build_impl_adj(bound_presolve.probing_cache, + problem.reverse_original_ids, + problem.n_variables, + stage_timer, + &findings); + if (stage_timer.check_time_limit()) { + CUOPT_LOG_DEBUG("Block-BVE hit its %.2fs phase limit building the implication graph", + stage_timer.get_time_limit()); + break; + } + + double work_units = 0.0; + bool proved_infeasible = false; + timer_t round_timer(stage_timer.clamp_remaining_time(deadline.remaining_time())); + const bool reduced = + block_bve_presolve(problem, impl_adj, round_timer, work_units, &findings, &proved_infeasible); + if (proved_infeasible) { + CUOPT_LOG_DEBUG("Block-BVE proved the problem infeasible"); + return false; + } + CUOPT_LOG_DEBUG("Block-BVE outer round %d/%d: reduced=%d vars %d->%d rows %d->%d", + round + 1, + BVE_MAX_ROUNDS, + (int)reduced, + n_vars_before, + problem.n_variables, + n_rows_before, + problem.n_constraints); + if (!reduced) { break; } + if (problem.n_variables >= n_vars_before) { break; } + if (n_vars_before - problem.n_variables < n_vars_before * BVE_MIN_ROUND_YIELD) { break; } + } + + // Harvest the projections: tighten the cache in place, pin the variables the blocks left with a + // single value, then propagate. + bound_presolve.probing_cache.merge_forcings(findings.forcings, findings.fixings); + i_t n_fixings = 0; + if (!deadline.check_time_limit()) { + if (!apply_bve_fixings(problem, findings.fixings, n_fixings)) { return false; } + if (n_fixings > 0) { trivial_presolve(problem, /*remap_cache_ids=*/true); } + } + const bool changed_model = problem.n_variables != n_vars_before_phase || + problem.n_constraints != n_rows_before_phase || n_fixings > 0; + if (changed_model) { + CUOPT_LOG_DEBUG("Block-BVE projections fixed %d variables", n_fixings); + if (!problem.empty && !deadline.check_time_limit()) { + bound_presolve.resize(problem); + auto term_crit = bound_presolve.solve(problem); + if (bound_presolve.infeas_constraints_count > 0) { return false; } + if (termination_criterion_t::NO_UPDATE != term_crit) { + bound_presolve.set_updated_bounds(problem); + } + } + } + return true; +} + +#define INSTANTIATE(F_TYPE) \ + template double bve_project_batch_gpu( \ + const raft::handle_t&, std::vector>&, F_TYPE, const timer_t&); \ + template std::vector> bve_build_impl_adj( \ + const probing_cache_t&, \ + const std::vector&, \ + int, \ + const timer_t&, \ + const probe_findings_t*); \ + template bool bve_has_stageable_row(const problem_t&); \ + template bool block_bve_phase( \ + bound_presolve_t&, problem_t&, const timer_t&); \ + template bool block_bve_presolve(problem_t&, \ + const std::vector>&, \ + timer_t&, \ + double&, \ + probe_findings_t*, \ + bool*, \ + int, \ + int, \ + int) + +#if MIP_INSTANTIATE_FLOAT +INSTANTIATE(float); +#endif + +#if MIP_INSTANTIATE_DOUBLE +INSTANTIATE(double); +#endif + +#undef INSTANTIATE + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/block_bve.cuh b/cpp/src/mip_heuristics/presolve/block_bve.cuh new file mode 100644 index 0000000000..08bb3b91f0 --- /dev/null +++ b/cpp/src/mip_heuristics/presolve/block_bve.cuh @@ -0,0 +1,167 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include +#include + +#include "bounds_presolve.cuh" +#include "probing_cache.cuh" + +#include + +#include +#include + +// Eliminates small blocks of zero-objective binary variables. A block is a set of columns to remove +// (the interior, na columns) together with every row they appear in; the other columns of those +// rows are the boundary (nb columns), which stays in the model and must also be binary. +// +// For each of the 2^nb boundary assignments the projection decides whether some interior +// assignment satisfies the block's rows. The ruled-out assignments are everything the block still +// forces on the rest of the model, so emitting them as prime-implicate no-goods over the boundary +// carries that force without the interior. Committing therefore deletes the interior columns and +// every block row, installing the no-goods in their place: interior variables disappear and the row +// count drops whenever the no-goods are fewer than the rows they replace, which the growth gate +// below requires. One feasible interior witness per surviving assignment is stored so postsolve +// can rebuild the deleted columns; since the interior carries no objective coefficients, any +// witness preserves the objective as well as feasibility. +// +// Candidate interiors are grown from the probing implication graph and committed only when the +// projected CNF satisfies the bounded-elimination growth limit of Eén and Biere, "Effective +// Preprocessing in SAT through Variable and Clause Elimination" (SAT 2005). Before commit, the +// emitted clauses are checked against the GPU-computed boundary feasibility table. + +namespace cuopt::mathematical_optimization::mip { + +// Caps for a single enumerated block. +static constexpr int BVE_MAX_BOUNDARY = 12; // nb <= 12 => 2^nb <= 4096 feasibility patterns +static constexpr int BVE_MAX_SCOPE = 16; // na + nb <= 16 +static constexpr int BVE_MAX_ROWS = 64; // rows spanned by the block; #clauses <= #rows +static constexpr int BVE_MAX_ROW_LEN = 24; // nnz within one block row (interior+boundary entries) +static constexpr int BVE_MAX_NNZ = BVE_MAX_ROWS * BVE_MAX_ROW_LEN; +static constexpr int BVE_MAX_CLAUSES = 64; // <= |rows| for any committed block +static constexpr int BVE_MAX_PATTERNS = 1 << BVE_MAX_BOUNDARY; + +// Packed projection block. Local ids [0, na) are interior and [na, na+nb) are boundary; rows use +// CSR layout and missing bounds are +/- infinity. +template +struct bve_block_t { + int na; // number of interior variables + int nb; // number of boundary variables + int n_rows; // rows spanned by the block + int row_off[BVE_MAX_ROWS + 1]; + int row_var[BVE_MAX_NNZ]; // local var id in [0, na+nb) + f_t row_coef[BVE_MAX_NNZ]; + f_t row_lo[BVE_MAX_ROWS]; // -inf if no lower bound + f_t row_up[BVE_MAX_ROWS]; // +inf if no upper bound +}; + +// Boundary clause forbidding patterns that match `bit_mask` at every position in `lit_mask`. +// It is emitted as sum_j (bit_j == 0 ? x_j : -x_j) >= 1 - popcount(bit_mask & lit_mask). +struct bve_clause_t { + uint32_t lit_mask; + uint32_t bit_mask; +}; + +// One bit per boundary pattern. The width tracks the block's own 2^nb, not BVE_MAX_PATTERNS, so +// raising BVE_MAX_BOUNDARY costs nothing on narrower blocks. +using bve_mask_t = std::vector; + +// Buffers the CNF construction reuses across blocks: `valid` alone is 4^nb bytes, so per-block +// allocation would dominate at wide boundaries. +struct bve_cover_scratch_t { + std::vector valid; // prime-cube validity table, grow-only + std::vector primes; + std::vector cover; // patterns matched by each prime + bve_mask_t uncovered; +}; + +// Derive a prime-implicate CNF from the boundary feasibility table by covering the infeasible +// patterns with a max-gain greedy over every prime forbidden cube; return -1 on cap overflow. +// Untemplated: the CNF is a Boolean computation over the feasibility table, and every dimension it +// touches is capped by the BVE_MAX_* constants above. +int bve_greedy_prime_cover(const uint8_t* feas, + int nb, + bve_clause_t* out, + int cap, + bve_cover_scratch_t& scratch, + int64_t* ops_out = nullptr); + +// Verify that the emitted clauses reproduce the boundary feasibility table exactly. +bool bve_sanity_check(const uint8_t* feas, int nb, const bve_clause_t* clauses, int n_clauses); + +// Exact existential projection of one block onto its boundary, filled by the projection backend. +// Both tables are sized to the block's own 2^nb rather than BVE_MAX_PATTERNS, so a narrow block +// does not carry the cost of raising BVE_MAX_BOUNDARY. +struct bve_projection_t { + std::vector feasible; // [2^nb] 1 iff the boundary pattern admits some interior + std::vector witness; // [2^nb] smallest feasible interior, 0 where infeasible + bool projected{false}; // set by the backend once both tables hold its result +}; + +// Staged candidate. Vector fields use sorted current-problem ids; `blk` uses local ids. +template +struct bve_candidate_t { + std::vector interior; // sorted global column ids (to be eliminated) + std::vector boundary; // sorted global column ids (kept) + std::vector rows; // sorted global row ids spanned by the block + bve_block_t blk; // gathered block, local ids, for the projection + bve_projection_t projection; // sized and zeroed by stage(), filled by the projection backend +}; + +// Project shape-binned candidate batches on the GPU and return a deterministic work estimate. +template +double bve_project_batch_gpu(const raft::handle_t& handle, + std::vector>& cands, + f_t tol, + const timer_t& timer); + +// Build symmetric current-problem implication adjacency from the original-id keyed probing cache, +// optionally unioned with forcings harvested from earlier block projections (also original-id). +// Returns an edgeless adjacency when `timer` expires, which leaves the pass with no seeds. +template +std::vector> bve_build_impl_adj( + const probing_cache_t& cache, + const std::vector& reverse_original_ids, + i_t n_vars, + const timer_t& timer, + const probe_findings_t* prior_original_id_findings = nullptr); + +// True when some row is short enough to appear in a staged block. A block's scope spans whole rows, +// so a model whose every row exceeds BVE_MAX_ROW_LEN has no stageable candidate no matter what the +// implication graph holds, and the pass can be skipped before that graph is built. +template +bool bve_has_stageable_row(const problem_t& problem); + +// cuOpt's block-BVE presolve phase, and the only entry point production code needs: bounded rounds +// of detect and install against the probing implication graph, followed by the projection harvest +// (cache tightening, single-value fixings, bound propagation). Mutates `problem` in place and +// returns false when the phase proved it infeasible. Requires a populated probing cache in +// `bound_presolve`; the caller decides whether the phase runs at all. +template +bool block_bve_phase(bound_presolve_t& bound_presolve, + problem_t& problem, + const timer_t& deadline); + +// Run block BVE using caller-provided implication adjacency and deadline. Returns true iff at least +// one validated reduction was installed; `work_units` receives a deterministic unscaled estimate. +// `out_findings`, when given, is appended with the implications read off every projected block +// (original-id frame), including blocks that were not eliminated. +template +bool block_bve_presolve(problem_t& problem, + const std::vector>& impl_adj, + timer_t& timer, + double& work_units, + probe_findings_t* out_findings = nullptr, + bool* out_infeasible = nullptr, + i_t boundary_cap = BVE_MAX_BOUNDARY, + i_t scope_cap = BVE_MAX_SCOPE, + i_t clause_growth_margin = 0); + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/probing_cache.cu b/cpp/src/mip_heuristics/presolve/probing_cache.cu index 38d076581f..d331f27f80 100644 --- a/cpp/src/mip_heuristics/presolve/probing_cache.cu +++ b/cpp/src/mip_heuristics/presolve/probing_cache.cu @@ -162,10 +162,15 @@ void inline insert_current_probing_to_cache(i_t var_idx, const std::vector& modified_lb, const std::vector& modified_ub, const std::vector& h_integer_indices, + const std::vector& original_ids, std::atomic& n_implied_singletons) { f_t int_tol = bound_presolve.context.settings.tolerances.integrality_tolerance; + cuopt_assert(var_idx >= 0 && var_idx < (i_t)original_ids.size(), + "probe var out of original_ids range"); + const i_t var_original = original_ids[var_idx]; + cache_entry_t cache_item; cache_item.val_interval = probe_val; for (auto impacted_var_idx : h_integer_indices) { @@ -180,18 +185,21 @@ void inline insert_current_probing_to_cache(i_t var_idx, "Lower bound must be greater than or equal to original lower bound"); cuopt_assert(modified_ub[impacted_var_idx] <= get_upper(original_var_bounds), "Upper bound must be less than or equal to original upper bound"); + cuopt_assert(impacted_var_idx >= 0 && impacted_var_idx < (i_t)original_ids.size(), + "impacted var out of original_ids range"); cached_bound_t new_bound{modified_lb[impacted_var_idx], modified_ub[impacted_var_idx]}; - cache_item.var_to_cached_bound_map.insert({impacted_var_idx, new_bound}); + // Map keys are original-frame ids (same frame as reverse_original_ids / bve_build_impl_adj). + cache_item.var_to_cached_bound_map.insert({original_ids[impacted_var_idx], new_bound}); } } { std::lock_guard lock(bound_presolve.probing_cache.probing_cache_mutex); - if (!bound_presolve.probing_cache.probing_cache.count(var_idx) > 0) { + if (!bound_presolve.probing_cache.probing_cache.count(var_original) > 0) { std::array, 2> entries_per_var; entries_per_var[0] = cache_item; - bound_presolve.probing_cache.probing_cache.insert({var_idx, entries_per_var}); + bound_presolve.probing_cache.probing_cache.insert({var_original, entries_per_var}); } else { - bound_presolve.probing_cache.probing_cache[var_idx][1] = cache_item; + bound_presolve.probing_cache.probing_cache[var_original][1] = cache_item; } } } @@ -497,6 +505,7 @@ void compute_cache_for_var(i_t var_idx, h_improved_lower_bounds, h_improved_upper_bounds, h_integer_indices, + problem.original_ids, n_of_implied_singletons); } } @@ -704,36 +713,69 @@ void apply_substitution_queue_to_problem( std::vector offset_values; std::vector coefficient_values; - // Get variable_mapping to convert current indices to original indices + // Get variable_mapping to convert current indices to post-Papilo frame auto h_variable_mapping = host_copy(problem.presolve_data.variable_mapping, problem.handle_ptr->get_stream()); problem.handle_ptr->sync_stream(); + // Staged rather than appended directly: all_substitutions is a hash map, so its iteration order + // is not reproducible, and the log's order has to be. merge_substitutions has already flattened + // chains and dropped bidirectional edges, so no record here depends on another and sorting by + // substituted_var only has to be a fixed order, not a particular one. + std::vector> staged_reconstructions; + staged_reconstructions.reserve(all_substitutions.size()); for (const auto& [substituting_var, substitutions] : all_substitutions) { for (const auto& [substituted_var, substitution] : substitutions) { CUOPT_LOG_TRACE("Applying substitution: %d -> %d", substitution.substituting_var, substitution.substituted_var); + cuopt_assert(substitution.substituted_var >= 0 && + substitution.substituted_var < (i_t)h_variable_mapping.size(), + "substituted_var out of variable_mapping range"); + cuopt_assert(substitution.substituting_var >= 0 && + substitution.substituting_var < (i_t)h_variable_mapping.size(), + "substituting_var out of variable_mapping range"); var_indices.push_back(substitution.substituted_var); substituting_var_indices.push_back(substitution.substituting_var); offset_values.push_back(substitution.offset); coefficient_values.push_back(substitution.coefficient); - // Store substitution for post-processing (convert to original variable IDs) - substitution_t sub; - sub.timestamp = substitution.timestamp; - sub.substituted_var = h_variable_mapping[substitution.substituted_var]; - sub.substituting_var = h_variable_mapping[substitution.substituting_var]; - sub.offset = substitution.offset; - sub.coefficient = substitution.coefficient; - problem.presolve_data.variable_substitutions.push_back(sub); - CUOPT_LOG_TRACE("Stored substitution for post-processing: x[%d] = %f + %f * x[%d]", - sub.substituted_var, - sub.offset, - sub.coefficient, - sub.substituting_var); + postsolve_reconstruction_t reconstruction; + reconstruction.kind = reconstruction_kind_t::AffineSub; + reconstruction.sub = substitution; + reconstruction.sub.substituted_var = h_variable_mapping[substitution.substituted_var]; + reconstruction.sub.substituting_var = h_variable_mapping[substitution.substituting_var]; + staged_reconstructions.push_back(std::move(reconstruction)); + CUOPT_LOG_TRACE("Stored AffineSub for post-processing: x[%d] = %f + %f * x[%d]", + staged_reconstructions.back().sub.substituted_var, + staged_reconstructions.back().sub.offset, + staged_reconstructions.back().sub.coefficient, + staged_reconstructions.back().sub.substituting_var); } } + // check are_exclusive to avoid emitting a nonsensical substitution with the variable on both + // sides + std::unordered_set substituting_vars(substituting_var_indices.begin(), + substituting_var_indices.end()); + for (i_t substituted_var : var_indices) { + if (substituting_vars.count(substituted_var) == 0) { continue; } + CUOPT_LOG_WARN( + "Skipping %zu probing substitutions: variable %d is both substituted and substituting", + var_indices.size(), + substituted_var); + return; + } + + std::sort(staged_reconstructions.begin(), + staged_reconstructions.end(), + [](const postsolve_reconstruction_t& a, + const postsolve_reconstruction_t& b) { + return a.sub.substituted_var < b.sub.substituted_var; + }); + auto& reconstructions = problem.presolve_data.postsolve_reconstructions; + reconstructions.insert(reconstructions.end(), + std::make_move_iterator(staged_reconstructions.begin()), + std::make_move_iterator(staged_reconstructions.end())); if (!var_indices.empty()) { problem.substitute_variables( @@ -853,6 +895,11 @@ bool compute_probing_cache(bound_presolve_t& bound_presolve, size_t step_size_hint) { raft::common::nvtx::range fun_scope("compute_probing_cache"); + + cuopt_assert(bound_presolve.probing_cache.probing_cache.empty(), + "probing cache is built once per solve"); + cuopt_assert(problem.original_ids.size() == (size_t)problem.n_variables, + "probing cache needs id maps that match the current column set"); // we dont want to compute the probing cache for all variables for time and computation resources auto priority_indices = compute_priority_indices_by_implied_integers(problem); CUOPT_LOG_DEBUG("Computing probing cache"); @@ -997,6 +1044,56 @@ bool compute_probing_cache(bound_presolve_t& bound_presolve, return problem_is_infeasible.load(); } +// incorporate implications discovered by block-BVE +template +void probing_cache_t::merge_forcings(const std::vector>& forcings, + std::vector>& fixings) +{ + i_t n_added = 0; + i_t n_tightened = 0; + i_t n_contradicted = 0; + for (const auto& forcing : forcings) { + cuopt_assert(forcing.var != forcing.forced_var, "self-forcing is not a projection finding"); + auto entry_it = probing_cache.find(forcing.var); + if (entry_it == probing_cache.end()) { continue; } + const f_t probed_val = forcing.value ? f_t(1) : f_t(0); + const f_t forced_val = forcing.forced_value ? f_t(1) : f_t(0); + for (cache_entry_t& entry : entry_it->second) { + if (entry.var_to_cached_bound_map.empty()) { continue; } + if (entry.val_interval.interval_type != interval_type_t::EQUALS) { continue; } + cuopt_assert(entry.val_interval.val == f_t(0) || entry.val_interval.val == f_t(1), ""); + if (entry.val_interval.val != probed_val) { continue; } + auto [bound_it, inserted] = entry.var_to_cached_bound_map.insert( + {forcing.forced_var, cached_bound_t{forced_val, forced_val}}); + if (inserted) { + ++n_added; + continue; + } + cached_bound_t& bound = bound_it->second; + cuopt_assert(bound.lb >= f_t(0) && bound.ub <= f_t(1), ""); + const f_t lb = std::max(bound.lb, forced_val); + const f_t ub = std::min(bound.ub, forced_val); + // Both the cached bound and the projection are valid and share the antecedent var == probed + // value, so an empty intersection proves only that the antecedent cannot hold. The slot is + // dead from here on, hence no tightening; the opposite value is the sound conclusion. + if (lb > ub) { + fixings.emplace_back(forcing.var, !forcing.value); + ++n_contradicted; + continue; + } + n_tightened += (lb != bound.lb || ub != bound.ub); + bound.lb = lb; + bound.ub = ub; + } + } + CUOPT_LOG_DEBUG( + "BVE forcings %zu: added %d and tightened %d probing cache bounds, %d contradicted a probe", + forcings.size(), + n_added, + n_tightened, + n_contradicted); +} + #define INSTANTIATE(F_TYPE) \ template bool compute_probing_cache(bound_presolve_t & bound_presolve, \ problem_t & problem, \ diff --git a/cpp/src/mip_heuristics/presolve/probing_cache.cuh b/cpp/src/mip_heuristics/presolve/probing_cache.cuh index 24d9a9cfc1..928b479533 100644 --- a/cpp/src/mip_heuristics/presolve/probing_cache.cuh +++ b/cpp/src/mip_heuristics/presolve/probing_cache.cuh @@ -73,6 +73,21 @@ struct cache_entry_t { std::unordered_map> var_to_cached_bound_map; }; +// A forcing read off an exactly projected block: var == value implies forced_var == forced_value. +template +struct probe_forcing_t { + i_t var; + i_t forced_var; + bool value; + bool forced_value; +}; + +template +struct probe_findings_t { + std::vector> forcings; + std::vector> fixings; // var forced to value by its block alone +}; + template class probing_cache_t { public: @@ -94,6 +109,8 @@ class probing_cache_t { f_t first_probe, f_t second_probe, f_t integrality_tolerance); + void merge_forcings(const std::vector>& forcings, + std::vector>& fixings); // add the results of probing cache to secondary CG structure if not already in a gub constraint. // use the same activity computation that we will use in BP rounding. // use GUB constraints to find fixings in bulk rounding diff --git a/cpp/src/mip_heuristics/presolve/trivial_presolve.cuh b/cpp/src/mip_heuristics/presolve/trivial_presolve.cuh index eff3b64910..5b4994d325 100644 --- a/cpp/src/mip_heuristics/presolve/trivial_presolve.cuh +++ b/cpp/src/mip_heuristics/presolve/trivial_presolve.cuh @@ -272,8 +272,10 @@ void update_from_csr(problem_t& pb, bool remap_cache_ids) cuopt_func_call(test_renumbered_coo(make_span(cnst, 0, nnz_edge_count), pb)); - auto updated_n_cnst = 1 + cnst_renum_ids.back_element(handle_ptr->get_stream()); - auto updated_n_vars = 1 + var_renum_ids.back_element(handle_ptr->get_stream()); + const i_t updated_n_cnst = + cnst_renum_ids.is_empty() ? 0 : 1 + cnst_renum_ids.back_element(handle_ptr->get_stream()); + const i_t updated_n_vars = + var_renum_ids.is_empty() ? 0 : 1 + var_renum_ids.back_element(handle_ptr->get_stream()); pb.n_constraints = updated_n_cnst; pb.n_variables = updated_n_vars; diff --git a/cpp/src/mip_heuristics/problem/presolve_data.cu b/cpp/src/mip_heuristics/problem/presolve_data.cu index e834ce8c21..3c621bc2cd 100644 --- a/cpp/src/mip_heuristics/problem/presolve_data.cu +++ b/cpp/src/mip_heuristics/problem/presolve_data.cu @@ -101,6 +101,47 @@ bool presolve_data_t::pre_process_assignment(problem_t& prob return true; } +template +static uint32_t boundary_pattern(const bve_reconstruction_t& bve, + const std::vector& assignment) +{ + cuopt_assert(bve.witness.size() == (size_t{1} << bve.boundary.size()), + "block witness size mismatch"); + uint32_t pattern = 0; + for (size_t j = 0; j < bve.boundary.size(); ++j) { + cuopt_assert(bve.boundary[j] < (i_t)assignment.size(), "block boundary out of bounds"); + const int bit = (assignment[bve.boundary[j]] > 0.5) ? 1 : 0; + pattern |= (uint32_t)bit << j; + } + return pattern; +} + +template +static void reconstruct_bve_block(const bve_reconstruction_t& bve, + std::vector& assignment) +{ + const uint32_t witness = bve.witness[boundary_pattern(bve, assignment)]; + for (size_t k = 0; k < bve.interior.size(); ++k) { + cuopt_assert(bve.interior[k] < (i_t)assignment.size(), "block interior out of bounds"); + assignment[bve.interior[k]] = (witness >> k) & 1u; + } +} + +template +static void reconstruct_affine_sub(const substitution_t& sub, + std::vector& assignment) +{ + cuopt_assert(sub.substituted_var < (i_t)assignment.size(), "substituted_var out of bounds"); + cuopt_assert(sub.substituting_var < (i_t)assignment.size(), "substituting_var out of bounds"); + assignment[sub.substituted_var] = sub.offset + sub.coefficient * 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, + assignment[sub.substituted_var]); +} + // 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 @@ -135,19 +176,12 @@ void presolve_data_t::post_process_assignment( } } - // 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]); + // Reverse-append undo of the unified GPU-presolve reconstruction log + for (auto it = postsolve_reconstructions.rbegin(); it != postsolve_reconstructions.rend(); ++it) { + switch (it->kind) { + case reconstruction_kind_t::BlockBve: reconstruct_bve_block(it->bve, h_assignment); break; + case reconstruction_kind_t::AffineSub: reconstruct_affine_sub(it->sub, h_assignment); break; + } } // this separate resizing is needed because of the callback diff --git a/cpp/src/mip_heuristics/problem/presolve_data.cuh b/cpp/src/mip_heuristics/problem/presolve_data.cuh index 5f0b7f53c3..713bb24c0d 100644 --- a/cpp/src/mip_heuristics/problem/presolve_data.cuh +++ b/cpp/src/mip_heuristics/problem/presolve_data.cuh @@ -34,6 +34,22 @@ struct substitution_t { f_t coefficient; }; +template +struct bve_reconstruction_t { + std::vector interior; + std::vector boundary; + std::vector witness; // size 2^boundary.size() +}; + +enum class reconstruction_kind_t : uint8_t { AffineSub = 0, BlockBve = 1 }; + +template +struct postsolve_reconstruction_t { + reconstruction_kind_t kind{}; + substitution_t sub{}; + bve_reconstruction_t bve{}; +}; + template class presolve_data_t { public: @@ -62,7 +78,7 @@ class presolve_data_t { 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) + postsolve_reconstructions(other.postsolve_reconstructions) { } @@ -76,7 +92,7 @@ class presolve_data_t { fixed_var_assignment.begin(), fixed_var_assignment.end(), 0.); - variable_substitutions.clear(); + postsolve_reconstructions.clear(); } void reset_additional_vars(const problem_t& problem, const raft::handle_t* handle_ptr) @@ -128,9 +144,9 @@ class presolve_data_t { 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; + // Append-only GPU-presolve reconstruction log (AffineSub from probing, BlockBve from block-BVE). + // post_process_assignment replays in reverse append order. + std::vector> postsolve_reconstructions; }; } // namespace mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/problem/problem.cu b/cpp/src/mip_heuristics/problem/problem.cu index ccba2d5f2b..17d55b7cc2 100644 --- a/cpp/src/mip_heuristics/problem/problem.cu +++ b/cpp/src/mip_heuristics/problem/problem.cu @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -204,6 +205,7 @@ problem_t::problem_t(const problem_t& problem_) var_names(problem_.var_names), row_names(problem_.row_names), objective_name(problem_.objective_name), + objective_offset(problem_.presolve_data.objective_offset), is_scaled_(problem_.is_scaled_), preprocess_called(problem_.preprocess_called), objective_is_integral(problem_.objective_is_integral), @@ -263,6 +265,7 @@ problem_t::problem_t(const problem_t& problem_, var_names(problem_.var_names), row_names(problem_.row_names), objective_name(problem_.objective_name), + objective_offset(problem_.presolve_data.objective_offset), is_scaled_(problem_.is_scaled_), preprocess_called(problem_.preprocess_called), objective_is_integral(problem_.objective_is_integral), @@ -365,6 +368,9 @@ problem_t::problem_t(const problem_t& problem_, bool no_deep var_names(problem_.var_names), row_names(problem_.row_names), objective_name(problem_.objective_name), + // presolve_data is declared ahead of this member and holds the live offset once presolve has + // moved it; problem_.objective_offset can be stale here. + objective_offset(presolve_data.objective_offset), is_scaled_(problem_.is_scaled_), preprocess_called(problem_.preprocess_called), objective_is_integral(problem_.objective_is_integral), @@ -1229,124 +1235,6 @@ void problem_t::insert_constraints(constraints_delta_t& h_co pdlp::combine_constraint_bounds(*this, combined_bounds); } -// Best rational approximation p/q to x with q <= max_denom, via continued fractions. -// Returns the last valid convergent if the denominator limit is reached. -std::pair rational_approximation(double x, int64_t max_denom, double epsilon) -{ - double ax = std::abs(x); - if (ax < epsilon) { return {0, 1}; } - - if (x < 0) { - auto [p, q] = rational_approximation(-x, max_denom, epsilon); - return {-p, q}; - } - - int64_t p_prev2 = 1, q_prev2 = 0; - int64_t p_prev1 = (int64_t)std::floor(x), q_prev1 = 1; - - double remainder = x - std::floor(x); - - for (int iter = 0; iter < 100; ++iter) { - if (std::abs(remainder) < 1e-15) break; - - remainder = 1.0 / remainder; - int64_t a = (int64_t)std::floor(remainder); - remainder -= a; - - int64_t p_curr = a * p_prev1 + p_prev2; - int64_t q_curr = a * q_prev1 + q_prev2; - - if (q_curr > max_denom) break; - // overflow guard - if (std::abs(p_curr) < std::abs(p_prev1)) break; - - p_prev2 = p_prev1; - q_prev2 = q_prev1; - p_prev1 = p_curr; - q_prev1 = q_curr; - - double approx_err = x - (double)p_curr / (double)q_curr; - if (std::abs(approx_err) < epsilon) break; - } - - return {p_prev1, q_prev1}; -} - -// Brute-force: try scalars 1..max_brute and return the smallest that makes all coefficients -// integral. -double find_scaling_brute_force(const std::vector& coefficients, - int max_brute = 100, - double tol = 1e-6) -{ - for (int s = 1; s <= max_brute; ++s) { - bool ok = true; - for (double c : coefficients) { - double scaled = s * c; - if (std::abs(scaled - std::round(scaled)) > tol) { - ok = false; - break; - } - } - if (ok) return (double)s; - } - return std::numeric_limits::quiet_NaN(); -} - -// Continued-fractions approach: rationalize each coefficient, compute scm/gcd incrementally. -double find_scaling_rational(const std::vector& coefficients, - double maxscale = 1e6, - int64_t maxdnom = 10000000, - double maxfinal = 10000, - double intcheck_tol = 1e-6) -{ - constexpr double no_scaling = std::numeric_limits::quiet_NaN(); - double epsilon = 1.0 / maxscale; - - int64_t gcd = 0; - int64_t scm = 1; - - for (double c : coefficients) { - auto [num, den] = rational_approximation(c, maxdnom, epsilon); - if (den == 0 || num == 0) continue; - - int64_t abs_num = std::abs(num); - if (gcd == 0) { - gcd = abs_num; - scm = den; - } else { - gcd = std::gcd(gcd, abs_num); - int64_t factor = den / std::gcd(scm, den); - int64_t new_scm; - if (__builtin_mul_overflow(scm, factor, &new_scm)) return no_scaling; - scm = new_scm; - } - - if ((double)scm / (double)gcd > maxscale) return no_scaling; - } - - if (gcd == 0) return 1.0; - - double intscalar = (double)scm / (double)gcd; - if (intscalar > maxfinal) return no_scaling; - - for (double c : coefficients) { - double scaled = intscalar * c; - if (std::abs(scaled - std::round(scaled)) > intcheck_tol) return no_scaling; - } - - return intscalar; -} - -// Finds the smallest integer scaling factor s such that s * c_i is integral for all i. -// Tries a brute-force sweep first (cheap, numerically robust), then falls back to -// continued fractions for larger scalars. -double find_objective_scaling_factor(const std::vector& coefficients) -{ - double s = find_scaling_brute_force(coefficients); - if (!std::isnan(s)) return s; - return find_scaling_rational(coefficients); -} - template void problem_t::set_implied_integers(const std::vector& implied_integer_indices) { @@ -2168,36 +2056,29 @@ void problem_t::set_constraints_from_host_user_problem( raft::common::nvtx::range fun_scope("set_constraints_from_host_user_problem"); cuopt_assert(user_problem.handle_ptr == handle_ptr, "handle mismatch"); cuopt_assert(user_problem.num_cols == n_variables, "num cols mismatch"); - n_constraints = user_problem.num_rows; - cuopt_assert(user_problem.rhs.size() == static_cast(n_constraints), "rhs size mismatch"); - cuopt_assert(user_problem.row_sense.size() == static_cast(n_constraints), + const i_t num_rows = user_problem.num_rows; + cuopt_assert(user_problem.rhs.size() == static_cast(num_rows), "rhs size mismatch"); + cuopt_assert(user_problem.row_sense.size() == static_cast(num_rows), "row sense size mismatch"); cuopt_assert(user_problem.range_rows.size() == user_problem.range_value.size(), "range rows/value size mismatch"); - csr_matrix_t csr_A(n_constraints, n_variables, user_problem.A.nnz()); + csr_matrix_t csr_A(num_rows, n_variables, user_problem.A.nnz()); user_problem.A.to_compressed_row(csr_A); - nnz = csr_A.row_start[n_constraints]; - empty = (nnz == 0 && n_constraints == 0 && n_variables == 0); - auto stream = handle_ptr->get_stream(); - cuopt::device_copy(coefficients, csr_A.x, stream); - cuopt::device_copy(variables, csr_A.j, stream); - cuopt::device_copy(offsets, csr_A.row_start, stream); - - std::vector h_constraint_lower_bounds(n_constraints); - std::vector h_constraint_upper_bounds(n_constraints); - std::vector range_value_per_row(n_constraints, f_t{0}); - std::vector is_range_row(n_constraints, 0); + std::vector h_constraint_lower_bounds(num_rows); + std::vector h_constraint_upper_bounds(num_rows); + std::vector range_value_per_row(num_rows, f_t{0}); + std::vector is_range_row(num_rows, 0); for (size_t idx = 0; idx < user_problem.range_rows.size(); ++idx) { auto row = user_problem.range_rows[idx]; - cuopt_assert(row >= 0 && row < n_constraints, "range row out of bounds"); + cuopt_assert(row >= 0 && row < num_rows, "range row out of bounds"); is_range_row[row] = 1; range_value_per_row[row] = user_problem.range_value[idx]; } const auto inf = std::numeric_limits::infinity(); - for (i_t i = 0; i < n_constraints; ++i) { + for (i_t i = 0; i < num_rows; ++i) { const f_t rhs = user_problem.rhs[i]; const char sense = user_problem.row_sense[i]; if (sense == 'E') { @@ -2214,32 +2095,66 @@ void problem_t::set_constraints_from_host_user_problem( cuopt_assert(false, "Unsupported row sense"); } } + set_constraints_from_host_csr(csr_A.row_start, + csr_A.j, + csr_A.x, + h_constraint_lower_bounds, + h_constraint_upper_bounds, + user_problem.row_names); +} - cuopt::device_copy(constraint_lower_bounds, h_constraint_lower_bounds, stream); - cuopt::device_copy(constraint_upper_bounds, h_constraint_upper_bounds, stream); - - if (!user_problem.row_names.empty()) { - row_names = user_problem.row_names; - } else if (row_names.size() != static_cast(n_constraints)) { - row_names.clear(); +template +void problem_t::set_constraints_from_host_csr(const std::vector& offsets_in, + const std::vector& variables_in, + const std::vector& coefficients_in, + const std::vector& row_lower, + const std::vector& row_upper, + const std::vector& names) +{ + raft::common::nvtx::range fun_scope("set_constraints_from_host_csr"); + n_constraints = static_cast(row_lower.size()); + cuopt_assert(row_upper.size() == static_cast(n_constraints), "row bound size mismatch"); + cuopt_assert(offsets_in.size() == static_cast(n_constraints) + 1, + "offsets size mismatch"); + cuopt_assert(!offsets_in.empty() && offsets_in.front() == 0, "invalid CSR offsets"); + cuopt_assert(std::is_sorted(offsets_in.begin(), offsets_in.end()), "unsorted CSR offsets"); + cuopt_assert(variables_in.size() == coefficients_in.size(), "csr index/value size mismatch"); + cuopt_assert(static_cast(offsets_in.back()) == variables_in.size(), + "CSR offsets/entries size mismatch"); + cuopt_assert(names.empty() || names.size() == static_cast(n_constraints), + "row names size mismatch"); + for (i_t variable : variables_in) { + cuopt_assert(variable >= 0 && variable < n_variables, "CSR variable out of bounds"); } + nnz = static_cast(variables_in.size()); + empty = (nnz == 0 && n_constraints == 0 && n_variables == 0); + auto stream = handle_ptr->get_stream(); + cuopt::device_copy(coefficients, coefficients_in, stream); + cuopt::device_copy(variables, variables_in, stream); + cuopt::device_copy(offsets, offsets_in, stream); + cuopt::device_copy(constraint_lower_bounds, row_lower, stream); + cuopt::device_copy(constraint_upper_bounds, row_upper, stream); + + // the previous row set is gone: drop stale row names and any fixed-problem cache + row_names = names; integer_fixed_problem = nullptr; + fixing_helpers.reduction_in_rhs.resize(n_constraints, stream); - auto prev_dual_size = lp_state.prev_dual.size(); + thrust::fill(handle_ptr->get_thrust_policy(), + fixing_helpers.reduction_in_rhs.begin(), + fixing_helpers.reduction_in_rhs.end(), + f_t{0}); lp_state.prev_dual.resize(n_constraints, stream); - if (n_constraints > (i_t)prev_dual_size) { - thrust::fill(handle_ptr->get_thrust_policy(), - lp_state.prev_dual.begin() + prev_dual_size, - lp_state.prev_dual.end(), - f_t{0}); - } + thrust::fill( + handle_ptr->get_thrust_policy(), lp_state.prev_dual.begin(), lp_state.prev_dual.end(), f_t{0}); handle_ptr->sync_stream(); RAFT_CHECK_CUDA(stream); compute_transpose_of_problem(); combined_bounds.resize(n_constraints, stream); pdlp::combine_constraint_bounds(*this, combined_bounds); + recompute_auxilliary_data(false); } template diff --git a/cpp/src/mip_heuristics/problem/problem.cuh b/cpp/src/mip_heuristics/problem/problem.cuh index bcc3f06fc2..3ea3973d1d 100644 --- a/cpp/src/mip_heuristics/problem/problem.cuh +++ b/cpp/src/mip_heuristics/problem/problem.cuh @@ -143,6 +143,14 @@ class problem_t { cuopt::mathematical_optimization::simplex::user_problem_t& user_problem) const; void set_constraints_from_host_user_problem( const cuopt::mathematical_optimization::simplex::user_problem_t& user_problem); + // Replace the constraint matrix + row bounds in place from host CSR + // Used by presolve passes that rewrite rows in place + void set_constraints_from_host_csr(const std::vector& offsets, + const std::vector& variables, + const std::vector& coefficients, + const std::vector& row_lower, + const std::vector& row_upper, + const std::vector& names); uint32_t get_fingerprint() const; @@ -326,7 +334,7 @@ class problem_t { std::vector row_names{}; /** name of the objective (only a single objective is currently allowed) */ std::string objective_name; - f_t objective_offset; + f_t objective_offset{0}; bool is_scaled_{false}; bool preprocess_called{false}; bool objective_is_integral{false}; diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index f55aca6878..162a5ba291 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -198,7 +198,7 @@ mip_solution_t run_mip_solver( scaled_problem.preprocess_problem(); scaled_problem.related_vars_time_limit = settings.heuristic_params.related_vars_time_limit; const i_t n_vars_before = scaled_problem.n_variables; - mip::trivial_presolve(scaled_problem); + mip::trivial_presolve(scaled_problem, /*remap_cache_ids=*/true); #ifdef DETECT_SYMMETRY_BEFORE_PRESOLVE // Trivial presolve may remove unused variables and renumber the remaining ones. diff --git a/cpp/src/utilities/integer_scaling.hpp b/cpp/src/utilities/integer_scaling.hpp new file mode 100644 index 0000000000..4456977698 --- /dev/null +++ b/cpp/src/utilities/integer_scaling.hpp @@ -0,0 +1,217 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace cuopt { + +namespace detail { + +// Best rational approximation p/q to x with q <= max_denom, via continued fractions. Returns the +// last valid convergent if the denominator limit is reached. +inline std::pair rational_approximation(double x, + int64_t max_denom, + double epsilon) +{ + cuopt_assert(std::isfinite(x), "non-finite coefficient"); + if (!std::isfinite(x)) return {0, 0}; + + double ax = std::abs(x); + if (ax < epsilon) { return {0, 1}; } + + if (x < 0) { + auto [p, q] = rational_approximation(-x, max_denom, epsilon); + return {-p, q}; + } + + const double integer_part = std::floor(x); + if (integer_part >= (double)std::numeric_limits::max()) return {0, 0}; + + int64_t p_prev2 = 1, q_prev2 = 0; + int64_t p_prev1 = (int64_t)integer_part, q_prev1 = 1; + + double remainder = x - integer_part; + + for (int iter = 0; iter < 100; ++iter) { + if (std::abs(remainder) < 1e-15) break; + + remainder = 1.0 / remainder; + const double quotient = std::floor(remainder); + if (!std::isfinite(quotient) || quotient >= (double)std::numeric_limits::max()) { + return {0, 0}; + } + int64_t a = (int64_t)quotient; + remainder -= a; + + int64_t p_product; + int64_t q_product; + int64_t p_curr; + int64_t q_curr; + if (__builtin_mul_overflow(a, p_prev1, &p_product) || + __builtin_add_overflow(p_product, p_prev2, &p_curr) || + __builtin_mul_overflow(a, q_prev1, &q_product) || + __builtin_add_overflow(q_product, q_prev2, &q_curr)) { + return {0, 0}; + } + + if (q_curr > max_denom) break; + + p_prev2 = p_prev1; + q_prev2 = q_prev1; + p_prev1 = p_curr; + q_prev1 = q_curr; + + double approx_err = x - (double)p_curr / (double)q_curr; + if (std::abs(approx_err) < epsilon) break; + } + + return {p_prev1, q_prev1}; +} + +// Brute-force: try scalars 1..max_brute and return the smallest that makes all coefficients +// integral. +inline double find_scaling_brute_force(const std::vector& coefficients, + int max_brute = 100, + double tol = 1e-6) +{ + for (int s = 1; s <= max_brute; ++s) { + bool ok = true; + for (double c : coefficients) { + cuopt_assert(std::isfinite(c), "non-finite coefficient"); + if (!std::isfinite(c)) return std::numeric_limits::quiet_NaN(); + double scaled = s * c; + if (!std::isfinite(scaled) || std::abs(scaled - std::round(scaled)) > tol) { + ok = false; + break; + } + } + if (ok) return (double)s; + } + return std::numeric_limits::quiet_NaN(); +} + +} // namespace detail + +// Continued-fractions approach: rationalize each coefficient, compute scm/gcd incrementally. +// Returns the smallest positive multiplier s such that s * c is (near-)integer for every c, or NaN +// if no such multiplier exists within the caps. +inline double find_scaling_rational(const std::vector& coefficients, + double maxscale = 1e6, + int64_t maxdnom = 10000000, + double maxfinal = 10000, + double intcheck_tol = 1e-6) +{ + constexpr double no_scaling = std::numeric_limits::quiet_NaN(); + double epsilon = 1.0 / maxscale; + + int64_t gcd = 0; + int64_t scm = 1; + + for (double c : coefficients) { + auto [num, den] = detail::rational_approximation(c, maxdnom, epsilon); + if (den == 0) return no_scaling; + if (num == 0) continue; + + if (num == std::numeric_limits::min()) return no_scaling; + int64_t abs_num = std::abs(num); + if (gcd == 0) { + gcd = abs_num; + scm = den; + } else { + gcd = std::gcd(gcd, abs_num); + int64_t factor = den / std::gcd(scm, den); + int64_t new_scm; + if (__builtin_mul_overflow(scm, factor, &new_scm)) return no_scaling; + scm = new_scm; + } + + if ((double)scm / (double)gcd > maxscale) return no_scaling; + } + + if (gcd == 0) return 1.0; + + double intscalar = (double)scm / (double)gcd; + if (intscalar > maxfinal) return no_scaling; + + for (double c : coefficients) { + double scaled = intscalar * c; + if (!std::isfinite(scaled) || std::abs(scaled - std::round(scaled)) > intcheck_tol) + return no_scaling; + } + + return intscalar; +} + +// Finds the smallest integer scaling factor s such that s * c_i is integral for all i. Tries a +// brute-force sweep first (cheap, numerically robust), then falls back to continued fractions for +// larger scalars. +inline double find_objective_scaling_factor(const std::vector& coefficients) +{ + double s = detail::find_scaling_brute_force(coefficients); + if (!std::isnan(s)) return s; + return find_scaling_rational(coefficients); +} + +// A bound counts as "infinite" if non-finite or at/above the solver's large-bound sentinel. +template +inline bool scaling_bound_finite(f_t x) +{ + return std::isfinite(x) && std::abs(x) < f_t(1e30); +} + +// An exact subset sum of at most max_len integer terms, plus the bound compare, must stay inside +// the mantissa of the type that holds the sum for it to never round: 2^24 for fp32, 2^53 for fp64. +// Callers store the scaled row back as f_t and sum it as f_t, so the budget follows f_t rather than +// the double used internally to search for the multiplier. +template +inline constexpr double exact_subset_sum_budget = + (double)(uint64_t{1} << std::numeric_limits::digits); + +template +inline double row_int_scale(const f_t* coef, int n, f_t lo, f_t up, int max_len, int64_t scale_cap) +{ + static_assert(std::is_floating_point_v, "row scaling is defined for floating point rows"); + static_assert(std::numeric_limits::digits < 64, "mantissa wider than the budget shift"); + cuopt_assert(n >= 0, "negative row length"); + cuopt_assert(n <= max_len, "row length exceeds the exactness budget length"); + cuopt_assert(scale_cap > 0, "non-positive scale cap"); + + std::vector vals; + vals.reserve(n + 2); + for (int k = 0; k < n; ++k) + vals.push_back((double)coef[k]); + if (scaling_bound_finite(lo)) vals.push_back((double)lo); + if (scaling_bound_finite(up)) vals.push_back((double)up); + + const double scale = find_scaling_rational(vals, + /*maxscale=*/1e12, + /*maxdnom=*/scale_cap, + /*maxfinal=*/(double)scale_cap, + /*intcheck_tol=*/1e-9); + if (!std::isfinite(scale) || scale <= 0.0) return 0.0; + + // guard so the subset sum (<= max_len integer terms) stays within f_t's mantissa + double maxabs = 0.0; + for (double v : vals) + maxabs = std::max(maxabs, std::abs(v * scale)); + if (maxabs * (double)max_len >= exact_subset_sum_budget) return 0.0; + + return scale; +} + +} // namespace cuopt diff --git a/cpp/tests/internal/CMakeLists.txt b/cpp/tests/internal/CMakeLists.txt index 2856c56f38..9b5cd2ecb7 100644 --- a/cpp/tests/internal/CMakeLists.txt +++ b/cpp/tests/internal/CMakeLists.txt @@ -27,6 +27,7 @@ ConfigureTest(NUMOPT_INTERNAL_TEST ${CUOPT_TEST_DIR}/mip/integer_with_real_bounds.cu ${CUOPT_TEST_DIR}/mip/empty_fixed_problems_test.cu ${CUOPT_TEST_DIR}/mip/presolve_test.cu + ${CUOPT_TEST_DIR}/mip/block_bve_test.cu ${CUOPT_TEST_DIR}/mip/gf2_presolve_test.cpp ${CUOPT_TEST_DIR}/mip/termination_test.cu ${CUOPT_TEST_DIR}/mip/determinism_test.cu diff --git a/cpp/tests/mip/block_bve_test.cu b/cpp/tests/mip/block_bve_test.cu new file mode 100644 index 0000000000..c1aca2f690 --- /dev/null +++ b/cpp/tests/mip/block_bve_test.cu @@ -0,0 +1,936 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "../linear_programming/utilities/pdlp_test_utilities.cuh" // gtest + make_path_absolute (mip_utils.cuh deps) +#include "mip_utils.cuh" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +// ---- host enumeration projection (the differential oracle) ---- + +template +inline bool bve_is_finite(f_t x) +{ + // finite iff it equals itself (rules out NaN) and is strictly within +/- inf + return (x == x) && (x < INFINITY) && (x > -INFINITY); +} + +// Feasibility of one packed row under a full local assignment `val` (length na+nb), with tolerance. +template +inline bool bve_row_sat(const bve_block_t& blk, int r, const int* val, f_t tol) +{ + f_t s = 0; + for (int k = blk.row_off[r]; k < blk.row_off[r + 1]; ++k) { + s += blk.row_coef[k] * (f_t)val[blk.row_var[k]]; + } + if (bve_is_finite(blk.row_up[r]) && s > blk.row_up[r] + tol) return false; + if (bve_is_finite(blk.row_lo[r]) && s < blk.row_lo[r] - tol) return false; + return true; +} + +// Project the block onto its boundary. `feas[m]` (length 2^nb) is set to 1 iff boundary pattern m +// (nb bits) admits SOME interior assignment satisfying every block row, and `witness[m]` receives +// the packed interior assignment (na bits) of the FIRST feasible completion. Both are left 0 for +// infeasible patterns. The GPU kernel must match this exactly. +template +inline void bve_project(const bve_block_t& blk, f_t tol, uint8_t* feas, uint32_t* witness) +{ + const int na = blk.na, nb = blk.nb; + int val[BVE_MAX_SCOPE]; + for (uint32_t m = 0; m < (1u << nb); ++m) { + for (int j = 0; j < nb; ++j) + val[na + j] = (m >> j) & 1u; + feas[m] = 0; + witness[m] = 0u; + for (uint32_t am = 0; am < (1u << na); ++am) { + for (int j = 0; j < na; ++j) + val[j] = (am >> j) & 1u; + bool ok = true; + for (int r = 0; r < blk.n_rows && ok; ++r) + ok = bve_row_sat(blk, r, val, tol); + if (ok) { + feas[m] = 1; + witness[m] = am; + break; + } + } + } +} + +enum class bve_status_t : int { + kReduced = 0, // sanity check passed; `clauses` is a sound replacement for the block rows + kSkipCaps = 1, // block violates a bound cap (defensive; detector should pre-filter) + kSkipGrowth = 2, // |clauses| > |rows| + margin (would grow the row count) + kSkipCheckFailed = + 3 // clauses did not reproduce feas (sanity check failed) => keep block verbatim +}; + +// Full per-block core on the host: project -> prime-implicate CNF -> growth gate -> inline sanity +// check. The production commit_projected does the same, but reads feas/witness from the GPU instead +// of the host bve_project above. +template +inline bve_status_t bve_project_and_check(const bve_block_t& blk, + f_t tol, + i_t margin, + bve_clause_t* clauses, + i_t* n_clauses, + uint32_t* witness) +{ + *n_clauses = 0; + if (blk.nb <= 0 || blk.nb > BVE_MAX_BOUNDARY) return bve_status_t::kSkipCaps; + if (blk.na < 0 || blk.na + blk.nb > BVE_MAX_SCOPE) return bve_status_t::kSkipCaps; + if (blk.n_rows < 0 || blk.n_rows > BVE_MAX_ROWS) return bve_status_t::kSkipCaps; + + uint8_t feas[BVE_MAX_PATTERNS]; + bve_project(blk, tol, feas, witness); + bve_cover_scratch_t scratch; + const int nc = bve_greedy_prime_cover(feas, blk.nb, clauses, BVE_MAX_CLAUSES, scratch); + if (nc < 0) return bve_status_t::kSkipGrowth; // clause explosion past cap + if (nc > blk.n_rows + margin) return bve_status_t::kSkipGrowth; + if (!bve_sanity_check(feas, blk.nb, clauses, nc)) return bve_status_t::kSkipCheckFailed; + *n_clauses = nc; + return bve_status_t::kReduced; +} + +} // namespace cuopt::mathematical_optimization::mip + +namespace cuopt::mathematical_optimization::test { + +namespace mip = cuopt::mathematical_optimization::mip; + +// A minimal "a = b OR c, with b+c <= 1 forced" block. `a` is the only zero-objective binary aux +// (b and c carry objective, so they stay on the boundary and are never absorbed into the interior). +// Eliminating `a` by exact projection leaves exactly ONE prime-implicate clause: b + c <= 1 (the +// boundary pattern b=c=1 is infeasible because it would force a=1 and violate a+b+c<=2). +static constexpr const char* kBlockLp = R"LP( +Minimize + obj: b + c +Subject To + r0: a - b >= 0 + r1: a - c >= 0 + r2: a + b + c <= 2 +Binaries + a + b + c +End +)LP"; + +// Same gadget with every row scaled by 1/2, so the block coefficients and bounds are FRACTIONAL. +// The feasible region (hence the reduction: b + c <= 1, `a` eliminated) is identical, as positive +// row scaling preserves feasibility. This forces block-BVE's per-row integerization +// (row_int_scale) to recover integer coefficients before the exact tol-0 projection; a wrong +// integerization breaks either the reduction or its reconstruction. +static constexpr const char* kFractionalBlockLp = R"LP( +Minimize + obj: b + c +Subject To + r0: 0.5 a - 0.5 b >= 0 + r1: 0.5 a - 0.5 c >= 0 + r2: 0.5 a + 0.5 b + 0.5 c <= 1 +Binaries + a + b + c +End +)LP"; + +// solve_mip opens an OMP team before MIP internals that use taskloops; probing_cache sizes its +// pool from omp_get_num_threads()-1 (0 outside a parallel region → silent no-op). +template +static void with_mip_omp_team(F&& f) +{ + const int num_threads = std::max(2, omp_get_max_threads()); + const int saved_max_active_levels = omp_get_max_active_levels(); + if (saved_max_active_levels < 2) { omp_set_max_active_levels(2); } +#pragma omp parallel num_threads(num_threads) + { +#pragma omp masked + { + f(); + } + } + if (saved_max_active_levels < 2) { omp_set_max_active_levels(saved_max_active_levels); } +} + +// Production implication adjacency: bounds → probing cache → trivial compact → bve_build_impl_adj. +// If `out_infeasible` is non-null, probing infeasibility is reported there (empty adj returned); +// otherwise the caller is assumed to expect a feasible instance and we ASSERT that. +static std::vector> probing_impl_adj(mip::problem_t& problem, + bool* out_infeasible = nullptr) +{ + mip_solver_settings_t settings{}; + cuopt::timer_t timer(30.0); + mip::mip_solver_t solver(problem, settings, timer); + problem.tolerances = settings.get_tolerances(); + mip::bound_presolve_t bound_presolve(solver.context); + + bool infeasible = false; + with_mip_omp_team([&]() { + auto term_crit = bound_presolve.solve(problem); + if (term_crit != mip::termination_criterion_t::NO_UPDATE) { + bound_presolve.set_updated_bounds(problem); + } + cuopt::timer_t probing_timer(30.0); + infeasible = mip::compute_probing_cache(bound_presolve, problem, probing_timer); + if (!infeasible) { + constexpr bool remap_cache_ids = true; + mip::trivial_presolve(problem, remap_cache_ids); + } + }); + if (out_infeasible != nullptr) { + *out_infeasible = infeasible; + } else { + EXPECT_FALSE(infeasible); + } + if (infeasible) { return {}; } + return mip::bve_build_impl_adj( + bound_presolve.probing_cache, problem.reverse_original_ids, problem.n_variables, timer); +} + +// Build one block by hand for the projection-core tests. Local ids: a=0 (interior), b=1, c=2. +static mip::bve_block_t make_block() +{ + const double INF = std::numeric_limits::infinity(); + mip::bve_block_t blk{}; + blk.na = 1; + blk.nb = 2; + blk.n_rows = 3; + int nz = 0; + auto row = [&](int r, std::initializer_list> terms, double lo, double up) { + blk.row_off[r] = nz; + for (const auto& t : terms) { + blk.row_var[nz] = t.first; + blk.row_coef[nz] = t.second; + ++nz; + } + blk.row_lo[r] = lo; + blk.row_up[r] = up; + }; + row(0, {{0, 1.0}, {1, -1.0}}, 0.0, INF); // a - b >= 0 + row(1, {{0, 1.0}, {2, -1.0}}, 0.0, INF); // a - c >= 0 + row(2, {{0, 1.0}, {1, 1.0}, {2, 1.0}}, -INF, 2.0); // a + b + c <= 2 + blk.row_off[blk.n_rows] = nz; + return blk; +} + +// --- 1. projection core: the block sanity checks, yields one clause and the right witness --- +TEST(block_bve_core, reduces_block_and_sanity_checks) +{ + auto blk = make_block(); + mip::bve_clause_t clauses[mip::BVE_MAX_CLAUSES]; + uint32_t witness[mip::BVE_MAX_PATTERNS]; + int n_clauses = 0; + auto st = mip::bve_project_and_check(blk, 1e-6, /*margin=*/0, clauses, &n_clauses, witness); + + EXPECT_EQ(st, mip::bve_status_t::kReduced); + ASSERT_EQ(n_clauses, 1); + // clause forbids boundary pattern b=1,c=1 (bits 0 and 1 both set): b + c <= 1 + EXPECT_EQ(clauses[0].lit_mask, 3u); + EXPECT_EQ(clauses[0].bit_mask, 3u); + // witness: (b=0,c=0)->a=0, (b=1,c=0)->a=1, (b=0,c=1)->a=1 + EXPECT_EQ(witness[0], 0u); + EXPECT_EQ(witness[1], 1u); + EXPECT_EQ(witness[2], 1u); +} + +// --- 2. sanity check safety: the INDEPENDENT clause evaluator rejects any clause set that +// misrepresents +// feas (the certifying-algorithm result check; not a machine-checkable certificate) --- +TEST(block_bve_core, sanity_check_rejects_corrupted_clauses) +{ + // feasible-pattern array for the block above (b=c=1 is the only infeasible pattern) + const uint8_t feas[4] = {1, 1, 1, 0}; + const mip::bve_clause_t correct[1] = {{3u, 3u}}; // b + c <= 1 + EXPECT_TRUE(mip::bve_sanity_check(feas, 2, correct, 1)); + + // dropping the clause entirely: the CNF would accept b=c=1, but feas forbids it -> rejected + EXPECT_FALSE(mip::bve_sanity_check(feas, 2, correct, 0)); + // a wrong clause (forbid b=1 only) makes a genuinely feasible pattern look infeasible -> rejected + const mip::bve_clause_t wrong[1] = {{1u, 1u}}; + EXPECT_FALSE(mip::bve_sanity_check(feas, 2, wrong, 1)); +} + +// --- the row integerization GATE. block-BVE scales each block row to integers via +// find_scaling_rational (strict caps mirroring row_int_scale) so the projection is exact at +// tolerance 0; a row that will not integerize within the caps must be REJECTED (NaN), never rounded +// into a different model. This pins the accept/reject decision that keeps large / non-rational +// coefficients off the exact-projection path. --- +TEST(block_bve_core, integer_scaling_accepts_rational_rejects_pathological) +{ + // Strict caps matching row_int_scale (maxdnom/maxfinal = BVE_INT_SCALE_MAX = 1e6). + const double kMaxScale = 1e12; + const int64_t kMaxDenom = 1000000; + const double kMaxFinal = 1e6; + const double kIntTol = 1e-9; + auto all_integer = [](double s, const std::vector& v) { + for (double c : v) + if (std::abs(s * c - std::round(s * c)) >= 1e-9) return false; + return true; + }; + + // Fractional-but-rational: {1/2, 1/4, -3/4, 1} integerize (expected multiplier 4). + { + std::vector v{0.5, 0.25, -0.75, 1.0}; + double s = cuopt::find_scaling_rational(v, kMaxScale, kMaxDenom, kMaxFinal, kIntTol); + ASSERT_TRUE(std::isfinite(s)) << "rational coefficients must integerize"; + EXPECT_GT(s, 0.0); + EXPECT_TRUE(all_integer(s, v)); + } + + // Large integer coefficients stay exact (already integer -> multiplier 1, no rounding). + { + std::vector v{1e9, -1e9, 3.0}; + double s = cuopt::find_scaling_rational(v, kMaxScale, kMaxDenom, kMaxFinal, kIntTol); + ASSERT_TRUE(std::isfinite(s)); + EXPECT_TRUE(all_integer(s, v)); + } + + // Pathological: distinct prime reciprocals need lcm(11,13,17,19,23) = 1062347 > maxfinal (1e6), + // so no bounded integer multiplier exists -> rejected (NaN), NOT silently rounded. + { + std::vector v{1.0 / 11, 1.0 / 13, 1.0 / 17, 1.0 / 19, 1.0 / 23}; + double s = cuopt::find_scaling_rational(v, kMaxScale, kMaxDenom, kMaxFinal, kIntTol); + EXPECT_TRUE(std::isnan(s)) << "un-integerizable coefficients must be rejected, got " << s; + } +} + +// A cached probe and a block projection are both valid, so they can only disagree when the +// antecedent they share is unsatisfiable. That fixes the variable to the opposite value; the model +// is infeasible only once both polarities are contradicted, which apply_bve_fixings derives from +// two fixings that disagree. An empty intersection on its own must not be reported as global +// infeasibility. +TEST(block_bve_core, cache_contradiction_fixes_the_variable_instead_of_failing) +{ + constexpr int var = 7; + constexpr int forced = 9; + + // Probing has x7 = 0 => x9 = 0; the exact projection has x7 = 0 => x9 = 1. Slot 1 is left + // unpopulated, which also exercises the empty-bound-map guard. + { + mip::probing_cache_t cache; + std::array, 2> entries{}; + entries[0].val_interval = {0.0, mip::interval_type_t::EQUALS}; + entries[0].var_to_cached_bound_map[forced] = {0.0, 0.0}; + cache.probing_cache.insert({var, entries}); + + std::vector> fixings; + cache.merge_forcings({{var, forced, false, true}}, fixings); + + ASSERT_EQ(fixings.size(), 1u) << "a contradicted probe yields one fixing, not infeasibility"; + EXPECT_EQ(fixings[0].first, var); + EXPECT_TRUE(fixings[0].second) << "x7 = 0 is disproved, so x7 = 1"; + } + + // Both polarities contradicted: the two disagreeing fixings are what proves infeasibility. + { + mip::probing_cache_t cache; + std::array, 2> entries{}; + entries[0].val_interval = {0.0, mip::interval_type_t::EQUALS}; + entries[0].var_to_cached_bound_map[forced] = {0.0, 0.0}; + entries[1].val_interval = {1.0, mip::interval_type_t::EQUALS}; + entries[1].var_to_cached_bound_map[forced] = {0.0, 0.0}; + cache.probing_cache.insert({var, entries}); + + std::vector> fixings; + cache.merge_forcings({{var, forced, false, true}, {var, forced, true, true}}, fixings); + + ASSERT_EQ(fixings.size(), 2u); + std::sort(fixings.begin(), fixings.end()); + EXPECT_EQ(fixings[0], std::make_pair(var, false)); + EXPECT_EQ(fixings[1], std::make_pair(var, true)); + } + + // A forcing consistent with the cached interval tightens it and fixes nothing. + { + mip::probing_cache_t cache; + std::array, 2> entries{}; + entries[0].val_interval = {0.0, mip::interval_type_t::EQUALS}; + entries[0].var_to_cached_bound_map[forced] = {0.0, 1.0}; + cache.probing_cache.insert({var, entries}); + + std::vector> fixings; + cache.merge_forcings({{var, forced, false, true}}, fixings); + + EXPECT_TRUE(fixings.empty()) << "a consistent forcing must not fix anything"; + const auto& bound = cache.probing_cache.at(var)[0].var_to_cached_bound_map.at(forced); + EXPECT_EQ(bound.lb, 1.0); + EXPECT_EQ(bound.ub, 1.0); + } +} + +// Build a random block LAYOUT (na/nb/n_rows + sparsity pattern), coefficients/bounds left unset. +// Reps of one shape reuse the SAME layout so they land in one GPU shape-bin (exercising the num>1 +// path). +static mip::bve_block_t make_block_layout(std::mt19937& rng, int na, int nb, int n_rows) +{ + const int scope = na + nb; + mip::bve_block_t blk{}; + blk.na = na; + blk.nb = nb; + blk.n_rows = n_rows; + std::uniform_int_distribution present(0, 1); // is a var in this row + int nz = 0; + for (int r = 0; r < n_rows; ++r) { + blk.row_off[r] = nz; + for (int v = 0; v < scope; ++v) + if (present(rng)) blk.row_var[nz++] = v; + if (nz == blk.row_off[r]) blk.row_var[nz++] = r % scope; // never leave an empty row + } + blk.row_off[n_rows] = nz; + return blk; +} + +// Fill a layout's coefficients (small integers) and bounds (randomly ±inf), leaving the pattern +// fixed. +static void randomize_block_data(std::mt19937& rng, mip::bve_block_t& blk) +{ + const double INF = std::numeric_limits::infinity(); + const double coefs[4] = {-2.0, -1.0, 1.0, 2.0}; + std::uniform_int_distribution coef_pick(0, 3); + std::uniform_int_distribution bnd_pick(0, 2); // 0:[lo,inf] 1:[-inf,up] 2:[lo,up] + for (int k = 0; k < blk.row_off[blk.n_rows]; ++k) + blk.row_coef[k] = coefs[coef_pick(rng)]; + for (int r = 0; r < blk.n_rows; ++r) { + const int terms = blk.row_off[r + 1] - blk.row_off[r]; + // Activity under 0/1 vars and coefs in {-2,-1,1,2} lies in [-2*terms, 2*terms]. Pick finite + // uppers in [0, 2*terms] so they can bind (not always equal to the loose max activity). + const double lo = -terms; + std::uniform_int_distribution up_pick(0, 2 * terms); + const double up = up_pick(rng); + const int kind = bnd_pick(rng); + blk.row_lo[r] = (kind == 1) ? -INF : lo; + blk.row_up[r] = (kind == 0) ? INF : up; + } +} + +// --- projection correctness: the GPU batch projection must equal the host enumeration oracle on a +// diverse batch (varied na/nb/rows, ±inf bounds, multiple distinct shapes, and >1-block bins). +// This is what pins projection correctness; the inline sanity check cannot (it trusts feas). +// Runs the same function two independent ways and asserts feas + witness agree everywhere. +TEST(block_bve_projection, gpu_batch_matches_host_oracle) +{ + const raft::handle_t handle_{}; + std::mt19937 rng(12345u); + + // several shapes, several blocks each; reps share a layout -> one shape-bin with num>1 + const int shapes[][3] = {{1, 2, 3}, {2, 2, 2}, {1, 3, 4}, {3, 3, 5}, {2, 4, 3}, {4, 2, 4}}; + std::vector> blocks; + for (const auto& s : shapes) { + const mip::bve_block_t layout = make_block_layout(rng, s[0], s[1], s[2]); + for (int rep = 0; rep < 6; ++rep) { + mip::bve_block_t blk = layout; + randomize_block_data(rng, blk); + blocks.push_back(blk); + } + } + + std::vector> cands(blocks.size()); + for (size_t i = 0; i < blocks.size(); ++i) + cands[i].blk = + blocks[i]; // the service reads only .blk; interior/boundary/rows are unused here + + cuopt::timer_t no_deadline(std::numeric_limits::infinity()); + mip::bve_project_batch_gpu(handle_, cands, 1e-6, no_deadline); + + for (size_t i = 0; i < blocks.size(); ++i) { + uint8_t exp_feas[mip::BVE_MAX_PATTERNS]; + uint32_t exp_wit[mip::BVE_MAX_PATTERNS]; + mip::bve_project(blocks[i], 1e-6, exp_feas, exp_wit); + const int patterns = 1 << blocks[i].nb; + for (int m = 0; m < patterns; ++m) { + EXPECT_EQ(cands[i].projection.feasible[m], exp_feas[m]) << "block " << i << " pattern " << m; + if (exp_feas[m]) // witness only defined for feasible patterns + EXPECT_EQ(cands[i].projection.witness[m], exp_wit[m]) << "block " << i << " pattern " << m; + } + } +} + +// Fill a layout with LARGE integer coefficients and integer bounds (all exact fp64 integers, well +// under 2^53), leaving the pattern fixed. This is the shape block-BVE feeds the projection after +// integerization, and the magnitude range where a 1e-6-tolerance fp test would be marginal but +// exact integer arithmetic is not. +static void randomize_block_data_integer(std::mt19937& rng, mip::bve_block_t& blk) +{ + const double INF = std::numeric_limits::infinity(); + const double coefs[] = {-2e6, -1e6, 1e6, 2e6, 5e6}; + std::uniform_int_distribution coef_pick(0, 4); + std::uniform_int_distribution bnd_pick(0, 2); // 0:[lo,inf] 1:[-inf,up] 2:[lo,up] + for (int k = 0; k < blk.row_off[blk.n_rows]; ++k) + blk.row_coef[k] = coefs[coef_pick(rng)]; + for (int r = 0; r < blk.n_rows; ++r) { + const int terms = blk.row_off[r + 1] - blk.row_off[r]; + // Activity lies in [-5e6*terms, 5e6*terms]; pick finite integer bounds (multiples of 1e6) that + // can bind. + const double lo = -5e6 * terms; + std::uniform_int_distribution up_pick(0, 2 * terms); + const double up = 1e6 * up_pick(rng); + const int kind = bnd_pick(rng); + blk.row_lo[r] = (kind == 1) ? -INF : lo; + blk.row_up[r] = (kind == 0) ? INF : up; + } +} + +// --- the EXACT projection path. Production integerizes each block and projects at tolerance 0; +// the 1e-6 differential test above never exercises that. On large-integer-coefficient blocks +// the GPU projection at tol 0 must still equal the host enumeration oracle at tol 0 everywhere. +// --- +TEST(block_bve_projection, exact_projection_matches_host_at_tol0) +{ + const raft::handle_t handle_{}; + std::mt19937 rng(2024u); + + const int shapes[][3] = {{1, 2, 3}, {2, 2, 2}, {1, 3, 4}, {3, 3, 5}, {2, 4, 3}}; + std::vector> blocks; + for (const auto& s : shapes) { + const mip::bve_block_t layout = make_block_layout(rng, s[0], s[1], s[2]); + for (int rep = 0; rep < 6; ++rep) { + mip::bve_block_t blk = layout; + randomize_block_data_integer(rng, blk); + blocks.push_back(blk); + } + } + + std::vector> cands(blocks.size()); + for (size_t i = 0; i < blocks.size(); ++i) + cands[i].blk = blocks[i]; + + cuopt::timer_t no_deadline(std::numeric_limits::infinity()); + mip::bve_project_batch_gpu(handle_, cands, 0.0, no_deadline); // exact: tol 0 + + for (size_t i = 0; i < blocks.size(); ++i) { + uint8_t exp_feas[mip::BVE_MAX_PATTERNS]; + uint32_t exp_wit[mip::BVE_MAX_PATTERNS]; + mip::bve_project(blocks[i], 0.0, exp_feas, exp_wit); + const int patterns = 1 << blocks[i].nb; + for (int m = 0; m < patterns; ++m) { + EXPECT_EQ(cands[i].projection.feasible[m], exp_feas[m]) << "block " << i << " pattern " << m; + if (exp_feas[m]) + EXPECT_EQ(cands[i].projection.witness[m], exp_wit[m]) << "block " << i << " pattern " << m; + } + } +} + +// --- 3. end-to-end: run the pass on a problem_t, then reconstruct through postsolve --- +TEST(block_bve_presolve, end_to_end_reduction_and_reconstruction) +{ + const raft::handle_t handle_{}; + auto model = io::read_lp_from_string(kBlockLp); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + mip::problem_t problem(op_problem); + problem.preprocess_problem(); + problem.presolve_data.initialize_var_mapping(problem, problem.handle_ptr); + const int n_before = problem.n_variables; + + auto impl_adj = probing_impl_adj(problem); + + cuopt::timer_t bve_timer(10.0); + double bve_work_units = 0.0; + const bool applied = mip::block_bve_presolve(problem, impl_adj, bve_timer, bve_work_units); + EXPECT_TRUE(applied); + EXPECT_EQ(problem.n_variables, n_before - 1); // exactly `a` eliminated + + // Set a reduced solution with the first surviving (boundary) variable = 1; whichever of b/c it + // is, the block forces a = 1, so a correct reconstruction must satisfy the ORIGINAL constraints. + std::vector reduced(problem.n_variables, 0.0); + if (!reduced.empty()) reduced[0] = 1.0; + rmm::device_uvector assignment(problem.n_variables, handle_.get_stream()); + raft::copy(assignment.data(), reduced.data(), reduced.size(), handle_.get_stream()); + problem.presolve_data.post_process_assignment(problem, assignment, /*resize_to_original=*/true); + auto full = cuopt::host_copy(assignment, handle_.get_stream()); + handle_.sync_stream(); + + ASSERT_EQ(full.size(), static_cast(n_before)); // expanded back to all three variables + // The reconstructed full assignment must satisfy EVERY original constraint. This is order- + // independent (no assumption about which index is a/b/c): if the eliminated aux is reconstructed + // wrongly, a - b >= 0 or a - c >= 0 is violated. Since one boundary variable is set to 1, a + // correct reconstruction forces the aux to 1, so the feasibility check below is exactly that + // correctness test. + auto m_off = model.get_constraint_matrix_offsets(); + auto m_var = model.get_constraint_matrix_indices(); + auto m_val = model.get_constraint_matrix_values(); + auto m_rl = model.get_constraint_lower_bounds(); + auto m_ru = model.get_constraint_upper_bounds(); + for (size_t r = 0; r + 1 < m_off.size(); ++r) { + double s = 0.0; + for (int k = m_off[r]; k < m_off[r + 1]; ++k) + s += m_val[k] * full[m_var[k]]; + EXPECT_GE(s, m_rl[r] - 1e-6); + EXPECT_LE(s, m_ru[r] + 1e-6); + } +} + +// --- end-to-end with fractional block coefficients (the same gadget, rows scaled by 1/2). The +// reduction and its reconstruction must be identical to the integer gadget: block-BVE has to +// integerize the 0.5 coefficients before the exact projection and undo it correctly at +// postsolve. +TEST(block_bve_presolve, fractional_gadget_reduces_and_reconstructs) +{ + const raft::handle_t handle_{}; + auto model = io::read_lp_from_string(kFractionalBlockLp); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + mip::problem_t problem(op_problem); + problem.preprocess_problem(); + problem.presolve_data.initialize_var_mapping(problem, problem.handle_ptr); + const int n_before = problem.n_variables; + + auto impl_adj = probing_impl_adj(problem); + + cuopt::timer_t bve_timer(10.0); + double bve_work_units = 0.0; + const bool applied = mip::block_bve_presolve(problem, impl_adj, bve_timer, bve_work_units); + // Probing/trivial may already have eliminated the aux; either way exactly one variable is gone. + EXPECT_TRUE(applied || problem.n_variables < n_before); + ASSERT_EQ(problem.n_variables, n_before - 1) << "fractional gadget did not eliminate the aux"; + + // Set the first surviving (boundary) variable to 1; a correct reconstruction forces the aux so + // the full assignment satisfies every ORIGINAL (fractional) constraint. + std::vector reduced(problem.n_variables, 0.0); + if (!reduced.empty()) reduced[0] = 1.0; + rmm::device_uvector assignment(problem.n_variables, handle_.get_stream()); + raft::copy(assignment.data(), reduced.data(), reduced.size(), handle_.get_stream()); + problem.presolve_data.post_process_assignment(problem, assignment, /*resize_to_original=*/true); + auto full = cuopt::host_copy(assignment, handle_.get_stream()); + handle_.sync_stream(); + + ASSERT_EQ(full.size(), static_cast(n_before)); + auto m_off = model.get_constraint_matrix_offsets(); + auto m_var = model.get_constraint_matrix_indices(); + auto m_val = model.get_constraint_matrix_values(); + auto m_rl = model.get_constraint_lower_bounds(); + auto m_ru = model.get_constraint_upper_bounds(); + for (size_t r = 0; r + 1 < m_off.size(); ++r) { + double s = 0.0; + for (int k = m_off[r]; k < m_off[r + 1]; ++k) + s += m_val[k] * full[m_var[k]]; + EXPECT_GE(s, m_rl[r] - 1e-6); + EXPECT_LE(s, m_ru[r] + 1e-6); + } +} + +// Brute-force the (small, binary) reduced problem_t: enumerate all 2^n assignments, return whether +// any is feasible, the min solver-space objective, and its argmin. +struct bve_bf_t { + bool found; + double solver_obj; + std::vector x; +}; +static bve_bf_t brute_force_binary(mip::problem_t& problem) +{ + auto stream = problem.handle_ptr->get_stream(); + auto h_off = cuopt::host_copy(problem.offsets, stream); + auto h_var = cuopt::host_copy(problem.variables, stream); + auto h_coef = cuopt::host_copy(problem.coefficients, stream); + auto h_clb = cuopt::host_copy(problem.constraint_lower_bounds, stream); + auto h_cub = cuopt::host_copy(problem.constraint_upper_bounds, stream); + auto h_obj = cuopt::host_copy(problem.objective_coefficients, stream); + auto h_vb = cuopt::host_copy(problem.variable_bounds, stream); + problem.handle_ptr->sync_stream(); + + const int nv = problem.n_variables; + const int nr = problem.n_constraints; + for (int v = 0; v < nv; ++v) { // corpus is pure 0-1 + EXPECT_NEAR(get_lower(h_vb[v]), 0.0, 1e-9); + EXPECT_NEAR(get_upper(h_vb[v]), 1.0, 1e-9); + } + + bve_bf_t r{false, 0.0, {}}; + const double eps = 1e-6; + const uint64_t total = (nv >= 63) ? 0 : (uint64_t{1} << nv); + std::vector x(nv); + for (uint64_t mask = 0; mask < total; ++mask) { + for (int v = 0; v < nv; ++v) + x[v] = (mask >> v) & 1u; + bool ok = true; + for (int rr = 0; rr < nr && ok; ++rr) { + double s = 0.0; + for (int k = h_off[rr]; k < h_off[rr + 1]; ++k) + s += h_coef[k] * x[h_var[k]]; + if (s < h_clb[rr] - eps || s > h_cub[rr] + eps) ok = false; + } + if (!ok) continue; + double obj = 0.0; + for (int v = 0; v < nv; ++v) + obj += h_obj[v] * x[v]; + if (!r.found || obj < r.solver_obj - eps) { + r.found = true; + r.solver_obj = obj; + r.x = x; + } + } + return r; +} + +TEST(block_bve_regression, all_feasible_projection_can_remove_the_last_rows) +{ + const raft::handle_t handle_{}; + auto model = io::read_mps( + make_path_absolute("mip/block_bve/all_feasible_projection.mps"), /*fixed_format=*/false); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + mip::problem_t problem(op_problem); + problem.preprocess_problem(); + problem.presolve_data.initialize_var_mapping(problem, problem.handle_ptr); + + bool probing_infeasible = false; + auto impl_adj = probing_impl_adj(problem, &probing_infeasible); + ASSERT_FALSE(probing_infeasible) << "probing must leave the all-feasible projection for BVE"; + ASSERT_TRUE( + std::any_of(impl_adj.begin(), impl_adj.end(), [](const auto& adj) { return !adj.empty(); })) + << "fixture must provide an implication edge for the zero-objective auxiliary"; + + cuopt::timer_t bve_timer(10.0); + double bve_work_units = 0.0; + bool applied = false; + ASSERT_NO_THROW(applied = mip::block_bve_presolve(problem, impl_adj, bve_timer, bve_work_units)); + ASSERT_TRUE(applied) << "the all-feasible projection should eliminate its auxiliary"; + + ASSERT_LE(problem.n_variables, 24); + const auto bf = brute_force_binary(problem); + ASSERT_TRUE(bf.found) << "eliminating a tautological projection changed feasibility"; + EXPECT_NEAR(bf.solver_obj, 0.0, 1e-6); +} + +TEST(block_bve_regression, all_infeasible_projection_remains_infeasible) +{ + const raft::handle_t handle_{}; + auto model = io::read_mps( + make_path_absolute("mip/block_bve/all_infeasible_projection.mps"), /*fixed_format=*/false); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + mip::problem_t problem(op_problem); + problem.preprocess_problem(); + problem.presolve_data.initialize_var_mapping(problem, problem.handle_ptr); + + bool probing_infeasible = false; + auto impl_adj = probing_impl_adj(problem, &probing_infeasible); + ASSERT_FALSE(probing_infeasible) + << "the fixture must reach BVE instead of being discharged by probing"; + ASSERT_TRUE( + std::any_of(impl_adj.begin(), impl_adj.end(), [](const auto& adj) { return !adj.empty(); })) + << "fixture must provide an implication edge for the zero-objective auxiliary"; + + cuopt::timer_t bve_timer(10.0); + double bve_work_units = 0.0; + ASSERT_NO_THROW((void)mip::block_bve_presolve(problem, impl_adj, bve_timer, bve_work_units)); + + ASSERT_LE(problem.n_variables, 24); + const auto bf = brute_force_binary(problem); + EXPECT_FALSE(bf.found) << "BVE lost the empty clause produced by the projection"; +} + +// Corpus of small 0-1 instances whose optima were cross-checked OFFLINE by brute force AND HiGHS. +// MPS live in datasets/mip/block_bve/; optima inlined here. Mix: gadget-rich (block-BVE fires), +// no-op/soundness (aux-with-objective, random feasible ILPs), and infeasible. +struct bve_case_t { + const char* file; + bool feasible; + double optimum; + bool expect_reduce; // gadget should shrink via probing and/or block-BVE +}; +static const bve_case_t kBveCases[] = { + {"mip/block_bve/or_used.mps", true, 1.0, true}, + {"mip/block_bve/and_used.mps", true, -2.0, true}, + {"mip/block_bve/neq_used.mps", true, -3.0, true}, + {"mip/block_bve/chain_or.mps", true, 1.0, true}, + {"mip/block_bve/two_gadgets.mps", true, 2.0, true}, + {"mip/block_bve/heavy_reduce.mps", true, 2.0, true}, + {"mip/block_bve/aux_with_obj.mps", true, 4.0, false}, + {"mip/block_bve/mixed.mps", true, -1.0, false}, + {"mip/block_bve/infeasible.mps", false, 0.0, false}, + {"mip/block_bve/random_a.mps", true, -3.0, false}, + {"mip/block_bve/random_b.mps", true, -5.0, false}, + {"mip/block_bve/random_c.mps", true, -1.0, false}, +}; + +// End-to-end equivalence: for each corpus instance, run the pass, brute-force the reduced model, +// and assert block-BVE preserved the answer. block-BVE is a PRIMAL, optimum-preserving reduction, +// so the bar is: reduced optimum == known optimum, the reduced optimum reconstructs to an +// ORIGINAL-feasible point with that objective, and infeasibility is preserved. This stresses the +// full detect -> project +// -> commit -> install -> reconstruct chain (incl. variable_mapping + witness replay), which the +// component tests above don't. +TEST(block_bve_equivalence, preserves_optimum_and_reconstruction_on_corpus) +{ + const raft::handle_t handle_{}; + bool any_reduced = false; + for (const auto& c : kBveCases) { + SCOPED_TRACE(c.file); + auto model = io::read_mps(make_path_absolute(c.file), /*fixed_format=*/false); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + mip::problem_t problem(op_problem); + problem.preprocess_problem(); + problem.presolve_data.initialize_var_mapping(problem, problem.handle_ptr); + const int n_before = problem.n_variables; + + bool probing_infeas = false; + auto impl_adj = probing_impl_adj(problem, &probing_infeas); + if (probing_infeas) { + EXPECT_FALSE(c.feasible) << "probing proved infeasible on a feasible instance"; + continue; + } + + cuopt::timer_t bve_timer(10.0); + double bve_work_units = 0.0; + const bool applied = mip::block_bve_presolve(problem, impl_adj, bve_timer, bve_work_units); + // Probing/trivial may already have eliminated the aux; BVE then correctly no-ops. + if (applied || problem.n_variables < n_before) { any_reduced = true; } + if (applied) { + EXPECT_LT(problem.n_variables, n_before) << "applied but variable count unchanged"; + } + if (c.expect_reduce) { + EXPECT_LT(problem.n_variables, n_before) + << "gadget fixture expected a reduction via probing and/or block-BVE"; + } + + ASSERT_LE(problem.n_variables, 24) << "brute force enumerates 2^n; keep the corpus small"; + auto bf = brute_force_binary(problem); + if (!c.feasible) { + // NOTE: if preprocess detects the infeasibility upstream and collapses the model, this may + // need to become a problem-status check instead of a no-feasible-point check. + EXPECT_FALSE(bf.found) << "reduced model is feasible but the instance is infeasible"; + continue; + } + ASSERT_TRUE(bf.found) << "reduced model is infeasible but the instance is feasible"; + + // The reduced optimum must reconstruct to an ORIGINAL-feasible point whose ORIGINAL objective + // equals the known optimum. This is offset/scaling-independent (evaluated directly on the + // original model) and catches both directions: a cut optimum -> recon_obj > optimum; a spurious + // better solution -> either the reconstruction is original-infeasible or recon_obj < optimum. + rmm::device_uvector assignment(problem.n_variables, handle_.get_stream()); + raft::copy(assignment.data(), bf.x.data(), bf.x.size(), handle_.get_stream()); + problem.presolve_data.post_process_assignment(problem, assignment, /*resize_to_original=*/true); + auto full = cuopt::host_copy(assignment, handle_.get_stream()); + handle_.sync_stream(); + + auto m_off = model.get_constraint_matrix_offsets(); + auto m_var = model.get_constraint_matrix_indices(); + auto m_val = model.get_constraint_matrix_values(); + auto m_rl = model.get_constraint_lower_bounds(); + auto m_ru = model.get_constraint_upper_bounds(); + for (size_t r = 0; r + 1 < m_off.size(); ++r) { + double s = 0.0; + for (int k = m_off[r]; k < m_off[r + 1]; ++k) + s += m_val[k] * full[m_var[k]]; + EXPECT_GE(s, m_rl[r] - 1e-6); + EXPECT_LE(s, m_ru[r] + 1e-6); + } + auto m_obj = model.get_objective_coefficients(); + ASSERT_EQ(full.size(), m_obj.size()) << "reconstruction is not in the original column frame"; + double recon_obj = 0.0; + for (size_t j = 0; j < m_obj.size(); ++j) + recon_obj += m_obj[j] * full[j]; + EXPECT_NEAR(recon_obj, c.optimum, 1e-6); + } + EXPECT_TRUE(any_reduced) << "corpus exercised no probing/block-BVE reduction path"; +} + +// Drive production MIP presolve (Papilo → cuOpt run_presolve) and optionally assert +// upper bounds on the reduced size. Pass std::numeric_limits::max() for a +// dimension to skip that check. +static void run_presolve_size_check(const char* relative_mps_path, + int max_vars = std::numeric_limits::max(), + int max_rows = std::numeric_limits::max()) +{ + const raft::handle_t handle_{}; + auto model = io::read_mps(make_path_absolute(relative_mps_path), + /*fixed_format=*/false); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + sort_csr(op_problem); + + mip_solver_settings_t settings{}; + settings.presolver = presolver_t::Papilo; + settings.probing = true; + settings.block_bve = true; + + auto papilo = std::make_unique>(); + auto result = papilo->apply_presolve_from_op_problem(op_problem, + problem_category_t::MIP, + settings.presolver, + /*dual_postsolve=*/false, + settings.tolerances.absolute_tolerance, + settings.tolerances.relative_tolerance, + /*time_limit=*/60.0, + /*num_cpu_threads=*/0); + ASSERT_NE(result.status, mip::third_party_presolve_status_t::INFEASIBLE) + << relative_mps_path << " infeasible after Papilo"; + ASSERT_NE(result.status, mip::third_party_presolve_status_t::UNBNDORINFEAS) + << relative_mps_path << " unbounded-or-infeasible after Papilo"; + ASSERT_NE(result.status, mip::third_party_presolve_status_t::UNBOUNDED) + << relative_mps_path << " unbounded after Papilo"; + + mip::problem_t problem(result.reduced_problem); + problem.set_papilo_presolve_data(papilo.get(), + result.reduced_to_original_map, + result.original_to_reduced_map, + op_problem.get_n_variables()); + problem.set_implied_integers(result.implied_integer_indices); + problem.preprocess_problem(); + mip::trivial_presolve(problem, /*remap_cache_ids=*/true); // mirrors solve.cu's setup + + cuopt::timer_t timer(120.0); + mip::mip_solver_t solver(problem, settings, timer); + problem.tolerances = settings.get_tolerances(); + mip::diversity_manager_t dm(solver.context); + + bool presolve_ok = false; + with_mip_omp_team([&]() { presolve_ok = dm.run_presolve(/*time_limit=*/60.0, timer); }); + + ASSERT_TRUE(presolve_ok) << relative_mps_path << " cuOpt run_presolve failed"; + if (max_vars != std::numeric_limits::max()) { + EXPECT_LT(problem.n_variables, max_vars) + << relative_mps_path << " reduced n_variables=" << problem.n_variables; + } + if (max_rows != std::numeric_limits::max()) { + EXPECT_LT(problem.n_constraints, max_rows) + << relative_mps_path << " reduced n_constraints=" << problem.n_constraints; + } +} + +TEST(block_bve_presolve, bnatt400_reduces_below_500_vars) +{ + run_presolve_size_check("mip/bnatt400.mps", /*max_vars=*/500); +} + +TEST(block_bve_presolve, bnatt500_reduces_below_500_vars) +{ + run_presolve_size_check("mip/bnatt500.mps", /*max_vars=*/500); +} + +} // namespace cuopt::mathematical_optimization::test diff --git a/datasets/mip/block_bve/all_feasible_projection.mps b/datasets/mip/block_bve/all_feasible_projection.mps new file mode 100644 index 0000000000..ef03aa9eb2 --- /dev/null +++ b/datasets/mip/block_bve/all_feasible_projection.mps @@ -0,0 +1,25 @@ +NAME BVE_ALL_FEASIBLE +ROWS + N Obj + L r0 + L r1 + G r2 +COLUMNS + MARK0000 'MARKER' 'INTORG' + a r0 1 + a r1 1 + a r2 1 + b Obj 1 + b r0 -1 + b r2 -1 + c Obj 1 + c r1 -1 + c r2 -1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r2 -1 +BOUNDS + BV BOUND a + BV BOUND b + BV BOUND c +ENDATA diff --git a/datasets/mip/block_bve/all_infeasible_projection.mps b/datasets/mip/block_bve/all_infeasible_projection.mps new file mode 100644 index 0000000000..d090b446c3 --- /dev/null +++ b/datasets/mip/block_bve/all_infeasible_projection.mps @@ -0,0 +1,54 @@ +NAME BVE_ALL_INFEASIBLE +ROWS + N Obj + G p000 + G p001 + G p010 + G p011 + G p100 + G p101 + G p110 + G p111 + L link +COLUMNS + MARK0000 'MARKER' 'INTORG' + a p000 1 + a p001 1 + a p010 1 + a p011 1 + a p100 -1 + a p101 -1 + a p110 -1 + a p111 -1 + a link 1 + b p000 1 + b p001 1 + b p010 -1 + b p011 -1 + b p100 1 + b p101 1 + b p110 -1 + b p111 -1 + c p000 1 + c p001 -1 + c p010 1 + c p011 -1 + c p100 1 + c p101 -1 + c p110 1 + c p111 -1 + x Obj 1 + x link -1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V p000 1 + RHS_V p011 -1 + RHS_V p101 -1 + RHS_V p110 -1 + RHS_V p111 -2 +BOUNDS + BV BOUND a + BV BOUND b + BV BOUND c + BV BOUND x +ENDATA diff --git a/datasets/mip/block_bve/and_used.mps b/datasets/mip/block_bve/and_used.mps new file mode 100644 index 0000000000..3708601858 --- /dev/null +++ b/datasets/mip/block_bve/and_used.mps @@ -0,0 +1,30 @@ +NAME +ROWS + N Obj + L r0 + L r1 + G r2 + G r3 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj -1 + c0 r0 -1 + c0 r2 -1 + c1 Obj -1 + c1 r1 -1 + c1 r2 -1 + c2 Obj 1 + c2 r3 -1 + c3 r0 1 + c3 r1 1 + c3 r2 1 + c3 r3 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r2 -1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 +ENDATA diff --git a/datasets/mip/block_bve/aux_with_obj.mps b/datasets/mip/block_bve/aux_with_obj.mps new file mode 100644 index 0000000000..ad097e0b9c --- /dev/null +++ b/datasets/mip/block_bve/aux_with_obj.mps @@ -0,0 +1,28 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + G r3 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c2 Obj 3 + c2 r0 1 + c2 r1 1 + c2 r2 1 + c2 r3 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r3 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 +ENDATA diff --git a/datasets/mip/block_bve/chain_or.mps b/datasets/mip/block_bve/chain_or.mps new file mode 100644 index 0000000000..c1e1e772f1 --- /dev/null +++ b/datasets/mip/block_bve/chain_or.mps @@ -0,0 +1,40 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + G r3 + G r4 + L r5 + G r6 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c2 Obj 1 + c2 r4 -1 + c2 r5 -1 + c3 r0 1 + c3 r1 1 + c3 r2 1 + c3 r3 -1 + c3 r5 -1 + c4 r3 1 + c4 r4 1 + c4 r5 1 + c4 r6 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r6 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 +ENDATA diff --git a/datasets/mip/block_bve/heavy_reduce.mps b/datasets/mip/block_bve/heavy_reduce.mps new file mode 100644 index 0000000000..4fd5d4ed45 --- /dev/null +++ b/datasets/mip/block_bve/heavy_reduce.mps @@ -0,0 +1,54 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + G r3 + G r4 + L r5 + L r6 + L r7 + G r8 + G r9 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c2 Obj 1 + c2 r3 -1 + c2 r5 -1 + c3 Obj 1 + c3 r4 -1 + c3 r5 -1 + c4 r0 1 + c4 r1 1 + c4 r2 1 + c4 r6 -1 + c4 r8 -1 + c5 r3 1 + c5 r4 1 + c5 r5 1 + c5 r7 -1 + c5 r8 -1 + c6 r6 1 + c6 r7 1 + c6 r8 1 + c6 r9 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r8 -1 + RHS_V r9 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 + BV BOUND c6 +ENDATA diff --git a/datasets/mip/block_bve/infeasible.mps b/datasets/mip/block_bve/infeasible.mps new file mode 100644 index 0000000000..2ecd497664 --- /dev/null +++ b/datasets/mip/block_bve/infeasible.mps @@ -0,0 +1,31 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + G r3 + L r4 + L r5 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c0 r4 1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c1 r5 1 + c2 r0 1 + c2 r1 1 + c2 r2 1 + c2 r3 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r3 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 +ENDATA diff --git a/datasets/mip/block_bve/mixed.mps b/datasets/mip/block_bve/mixed.mps new file mode 100644 index 0000000000..424aeb95a5 --- /dev/null +++ b/datasets/mip/block_bve/mixed.mps @@ -0,0 +1,55 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + L r3 + L r4 + G r5 + L r6 + L r7 + L r8 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c0 r7 -1 + c0 r8 1 + c1 Obj -1 + c1 r1 -1 + c1 r2 -1 + c1 r3 -1 + c1 r5 -1 + c1 r8 1 + c2 Obj 2 + c2 r4 -1 + c2 r5 -1 + c2 r8 1 + c3 Obj -1 + c3 r6 1 + c3 r8 1 + c4 r0 1 + c4 r1 1 + c4 r2 1 + c4 r6 1 + c5 r3 1 + c5 r4 1 + c5 r5 1 + c5 r7 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r5 -1 + RHS_V r6 1 + RHS_V r8 3 +RANGES + RANGE r8 2 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 +ENDATA diff --git a/datasets/mip/block_bve/neq_used.mps b/datasets/mip/block_bve/neq_used.mps new file mode 100644 index 0000000000..3eff52e2c7 --- /dev/null +++ b/datasets/mip/block_bve/neq_used.mps @@ -0,0 +1,37 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + L r3 + L r4 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r1 1 + c0 r2 -1 + c0 r3 1 + c1 Obj 1 + c1 r0 1 + c1 r1 -1 + c1 r2 -1 + c1 r3 1 + c2 Obj -3 + c2 r4 1 + c3 r0 1 + c3 r1 1 + c3 r2 1 + c3 r3 1 + c3 r4 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r3 2 + RHS_V r4 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 +ENDATA diff --git a/datasets/mip/block_bve/or_used.mps b/datasets/mip/block_bve/or_used.mps new file mode 100644 index 0000000000..18b3789a91 --- /dev/null +++ b/datasets/mip/block_bve/or_used.mps @@ -0,0 +1,34 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + L r3 + G r4 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c0 r4 1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c1 r4 1 + c2 Obj -2 + c2 r3 1 + c3 r0 1 + c3 r1 1 + c3 r2 1 + c3 r3 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r3 1 + RHS_V r4 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 +ENDATA diff --git a/datasets/mip/block_bve/random_a.mps b/datasets/mip/block_bve/random_a.mps new file mode 100644 index 0000000000..b951a629e1 --- /dev/null +++ b/datasets/mip/block_bve/random_a.mps @@ -0,0 +1,37 @@ +NAME +ROWS + N Obj + G r0 + L r1 + G r2 + G r3 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj -1 + c0 r1 -2 + c0 r2 2 + c1 Obj 2 + c1 r3 -1 + c2 Obj -2 + c2 r2 -1 + c2 r3 2 + c3 r0 -2 + c3 r2 -2 + c4 Obj -2 + c4 r1 1 + c4 r2 -1 + c4 r3 -2 + c5 Obj 1 + c5 r0 2 + c5 r3 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r1 -2 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 +ENDATA diff --git a/datasets/mip/block_bve/random_b.mps b/datasets/mip/block_bve/random_b.mps new file mode 100644 index 0000000000..f81788b761 --- /dev/null +++ b/datasets/mip/block_bve/random_b.mps @@ -0,0 +1,54 @@ +NAME +ROWS + N Obj + G r0 + L r1 + L r2 + G r3 + G r4 + G r5 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj -2 + c0 r0 2 + c1 Obj -2 + c1 r1 -1 + c2 Obj -2 + c2 r0 1 + c2 r1 -1 + c2 r3 2 + c3 r3 2 + c3 r4 2 + c4 Obj -1 + c4 r1 -2 + c4 r2 2 + c4 r5 2 + c5 r1 1 + c5 r2 -1 + c5 r4 1 + c6 r2 1 + c6 r3 -1 + c6 r4 -1 + c6 r5 1 + c7 Obj 2 + c7 r2 2 + c7 r4 2 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r0 -1 + RHS_V r1 -1 + RHS_V r2 5 + RHS_V r4 2 + RHS_V r5 1 +RANGES + RANGE r2 2 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 + BV BOUND c6 + BV BOUND c7 +ENDATA diff --git a/datasets/mip/block_bve/random_c.mps b/datasets/mip/block_bve/random_c.mps new file mode 100644 index 0000000000..83aaee35e0 --- /dev/null +++ b/datasets/mip/block_bve/random_c.mps @@ -0,0 +1,48 @@ +NAME +ROWS + N Obj + L r0 + L r1 + L r2 + G r3 + L r4 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj -1 + c0 r3 2 + c1 Obj 2 + c1 r1 -2 + c1 r3 -1 + c2 Obj 2 + c2 r4 1 + c3 Obj -1 + c3 r0 -1 + c3 r2 2 + c4 r0 -1 + c4 r1 1 + c4 r2 2 + c5 Obj 2 + c5 r0 2 + c5 r2 2 + c5 r4 2 + c6 Obj 1 + c6 r0 -1 + c6 r4 2 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r1 1 + RHS_V r2 3 + RHS_V r3 -2 + RHS_V r4 5 +RANGES + RANGE r0 2 + RANGE r4 3 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 + BV BOUND c6 +ENDATA diff --git a/datasets/mip/block_bve/two_gadgets.mps b/datasets/mip/block_bve/two_gadgets.mps new file mode 100644 index 0000000000..7bb2e51137 --- /dev/null +++ b/datasets/mip/block_bve/two_gadgets.mps @@ -0,0 +1,49 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + L r3 + L r4 + G r5 + G r6 + G r7 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c0 r7 1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c1 r7 1 + c2 Obj 1 + c2 r3 -1 + c2 r5 -1 + c2 r7 1 + c3 Obj 1 + c3 r4 -1 + c3 r5 -1 + c3 r7 1 + c4 r0 1 + c4 r1 1 + c4 r2 1 + c4 r6 1 + c5 r3 1 + c5 r4 1 + c5 r5 1 + c5 r6 -1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r5 -1 + RHS_V r7 2 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 +ENDATA diff --git a/skills/cuopt-developer/SKILL.md b/skills/cuopt-developer/SKILL.md index 4aae65c8be..9d3bdb2fe4 100644 --- a/skills/cuopt-developer/SKILL.md +++ b/skills/cuopt-developer/SKILL.md @@ -171,6 +171,7 @@ cuopt/ - Keep operations stream-ordered - Follow existing RAFT/RMM patterns - No raw `new`/`delete` - use RMM allocators +- Prefer modern CCCL bit/math helpers in kernels (`cuda::bitfield_extract`, `cuda::bitmask`, pow2 utilities) over hand-rolled `%`/`/` by runtime powers of two — see [references/conventions.md](references/conventions.md) ## Build & Test @@ -224,7 +225,7 @@ For pre-commit setup, DCO sign-off (`git commit -s`), the fork-based PR workflow ## Coding Conventions -For C++ naming (`snake_case`, `d_`/`h_` prefixes, `_t` suffix), file extensions (`.hpp`/`.cpp`/`.cu`/`.cuh` and which compiler each uses), include order, Python style, error handling (`CUOPT_EXPECTS`, `RAFT_CUDA_TRY`), memory management (RMM patterns, no raw `new`/`delete`), test-impact rules, volatile-comment rules (hardware names and self-referential issue/PR numbers in comments or skip messages go stale; issue links to a separate tracking issue are fine), **no large local lambdas** (extract named helpers instead), and **coarse work-estimate / time-limit gating** (phase/outer-loop only; no fine inner-loop or double checks), see [references/conventions.md](references/conventions.md). +For C++ naming (`snake_case`, `d_`/`h_` prefixes, `_t` suffix), file extensions (`.hpp`/`.cpp`/`.cu`/`.cuh` and which compiler each uses), include order, Python style, error handling (`CUOPT_EXPECTS`, `RAFT_CUDA_TRY`), memory management (RMM patterns, no raw `new`/`delete`), CCCL bit/math helpers in device code, test-impact rules, volatile-comment rules (hardware names and self-referential issue/PR numbers in comments or skip messages go stale; issue links to a separate tracking issue are fine), **no large local lambdas** (extract named helpers instead), and **coarse work-estimate / time-limit gating** (phase/outer-loop only; no fine inner-loop or double checks), see [references/conventions.md](references/conventions.md). ## OpenMP task/runtime compatibility diff --git a/skills/cuopt-developer/references/conventions.md b/skills/cuopt-developer/references/conventions.md index 8ab14eb205..1bef2bbe3a 100644 --- a/skills/cuopt-developer/references/conventions.md +++ b/skills/cuopt-developer/references/conventions.md @@ -137,12 +137,51 @@ signed subtraction (`std::vector v(static_cast(hi - lo) + 2, 0)`), the narrowing `size_t`→`i_t` in `static_cast(x.size())` (established style; keep it) +### Integer widths — prefer fixed-width types + +Prefer `` fixed-width types (`int32_t`, `int64_t`, `uint32_t`, …) over +plain `int` / `long` / `long long` when the value range or ABI width matters +(counts that can exceed 32 bits, device grid math, work estimates, file offsets). + +Avoid multi-word functional casts such as `long long(x)` in `.cu`/`.cuh` — they +confuse CUDA-aware tooling (`type name is not allowed`). Use a C-style cast to a +fixed-width type instead: `(int64_t)x`. + +```cpp +const long long total = long long(num) * long long(patterns); // ❌ +const int64_t total = (int64_t)num * (int64_t)patterns; // ✅ +``` + +Keep `i_t` / `f_t` for problem-index and numeric template parameters; use +`int64_t` (etc.) for host-side wide counters outside that abstraction. + ### CUDA Error Checking ```cpp RAFT_CUDA_TRY(cudaMemcpy(...)); ``` +### Prefer modern CCCL utilities in device code + +When writing or editing CUDA kernels, prefer CCCL / libcu++ helpers over hand-rolled +bit math, reductions, or integer tricks. They encode the PTX-friendly form and avoid +boilerplate that compilers often fail to recover from runtime values. + +Examples (CUDA 13 / CCCL 3.x era — headers already used elsewhere in `cpp/src`): + +| Need | Prefer | Instead of | +|------|--------|------------| +| Extract a bitfield / decode packed indices | `cuda::bitfield_extract` (``) | `%` / `/` by a runtime `1 << k` (nvcc usually will not strength-reduce those to mask/shift) | +| Build a contiguous bit mask | `cuda::bitmask` | Hand-written `((1u << w) - 1u) << start` | +| Test / round to power of two | `cuda::is_power_of_two`, `next_power_of_two`, `prev_power_of_two` (``), or `cuda::std::has_single_bit` / `bit_ceil` / `bit_floor` (``) | Ad-hoc `(x & (x - 1)) == 0` / manual ceil loops | +| Divide/mod by a value that is constant for a launch (or across many ops) but not a compile-time constant | `cuda::fast_mod_div` (``) — construct on the host (or once), pass into the kernel, use `/` `%` / `cuda::div` | Hot-path `idiv` / handwritten libdivide magic | +| Warp/block algorithms | CUB / CCCL / RAFT primitives already used in-tree | Homegrown shared-memory reductions when an existing primitive fits | + +Docs: [CCCL bit extensions](https://nvidia.github.io/cccl/unstable/libcudacxx/extended_api/bit.html), +[pow2 helpers](https://nvidia.github.io/cccl/unstable/libcudacxx/extended_api/math/pow2.html), +[`cuda::fast_mod_div`](https://nvidia.github.io/cccl/unstable/libcudacxx/extended_api/math/fast_mod_div.html). +Check signatures in the installed headers rather than guessing — APIs evolve with CCCL. + ## Memory Management ```cpp @@ -169,3 +208,15 @@ Read existing code in `cpp/src/` for real examples of RMM allocation, stream-ord - Python pytest: `python/.../tests/` **Add at least one regression test for new behavior.** + +When a new MIP test loads a MIPLIB instance (e.g. via `make_path_absolute("mip/.mps")`), +that instance must appear in `datasets/mip/download_miplib_test_dataset.sh`'s `INSTANCES` +list. CI and local setups only fetch that allowlist — an unlisted name fails at parse time +with a missing-file error even though the test itself is correct. Add the basename there as part of the same change that introduces the test. + +Calling cuOpt MIP internals that use OpenMP taskloops (notably `diversity_manager::run_presolve` +→ `compute_probing_cache`) from a plain gtest must open an OMP team first, the same way +`solve_mip` does (`#pragma omp parallel num_threads(...)` + `#pragma omp masked`, with +`omp_set_max_active_levels(2)` if needed). Probing sizes its pool as +`omp_get_num_threads() - 1`; outside a parallel region that is 0 and probing becomes a silent +no-op (Papilo size unchanged, test finishes in a few hundred ms). From 3495483fdd074d3cbb8aa0c414aee2b7ca9d3451 Mon Sep 17 00:00:00 2001 From: Alice Boucher <160623740+aliceb-nv@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:18:47 +0200 Subject: [PATCH 05/78] Add a binary 2opt move fed from the probing cache into CPUFJ (#1738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Issue Authors: - Alice Boucher (https://github.com/aliceb-nv) Approvers: - Akif ÇÖRDÜK (https://github.com/akifcorduk) URL: https://github.com/NVIDIA/cuopt/pull/1738 --- .../feasibility_jump/feasibility_jump.cuh | 8 + .../feasibility_jump_impl_common.cuh | 2 +- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 326 +++++++++++++++++- .../feasibility_jump/fj_cpu.cuh | 27 ++ .../local_search/local_search.cu | 16 +- .../mip_heuristics/presolve/probing_cache.cuh | 2 +- 6 files changed, 362 insertions(+), 19 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh index 8d1f39ce22..ac1da031e3 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh @@ -75,6 +75,10 @@ struct fj_hyper_parameters_t { double small_move_tabu_threshold = 1e-6; int small_move_tabu_tenure = 4; + int two_opt_max_rows = 4; + int two_opt_max_row_vars = 256; + int two_opt_max_pairs = 256; + // load-balancing related settings int old_codepath_total_var_to_relvar_ratio_threshold = 200; int load_balancing_codepath_min_varcount = 3200; @@ -198,6 +202,9 @@ struct fj_move_candidate_t { template struct fj_cpu_climber_t; +template +class probing_cache_t; + template class fj_t { public: @@ -215,6 +222,7 @@ class fj_t { const std::vector& right_weights, f_t objective_weight, std::atomic& preemption_flag, + const probing_cache_t* probing_cache, fj_settings_t settings = fj_settings_t{}, bool randomize_params = false); i_t alloc_max_climbers(i_t desired_climbers); diff --git a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh index 98267f117c..046e138c5b 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh @@ -171,7 +171,7 @@ HDI std::pair feas_score_constraint( base_feas += (i_t)(cstr_weight * fj.settings->parameters.excess_improvement_weight); } // simple worsening - else if (!old_sat && !new_sat && old_lhs <= new_lhs) { + else if (!old_sat && !new_sat && old_lhs < new_lhs) { cuopt_assert(old_viol && new_viol, ""); base_feas -= (i_t)(cstr_weight * fj.settings->parameters.excess_improvement_weight); } diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 57a6a89479..5d9b0267b1 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -15,6 +15,8 @@ #include "fj_cpu.cuh" #include "fj_cpu_worker.cuh" +#include + #include #include @@ -132,7 +134,6 @@ thrust::tuple get_mtm_for_constraint( template std::pair feas_score_constraint(const typename fj_t::climber_data_t::view_t& fj, - i_t var_idx, f_t delta, i_t cstr_idx, f_t cstr_coeff, @@ -205,7 +206,7 @@ std::pair feas_score_constraint(const typename fj_t::climber base_feas += (i_t)(cstr_weight * fj.settings->parameters.excess_improvement_weight); } // simple worsening - else if (!old_sat && !new_sat && old_lhs <= new_lhs) { + else if (!old_sat && !new_sat && old_lhs < new_lhs) { cuopt_assert(old_viol && new_viol, ""); base_feas -= (i_t)(cstr_weight * fj.settings->parameters.excess_improvement_weight); } @@ -613,7 +614,6 @@ static inline std::pair compute_score(fj_cpu_climber_t(fj_cpu.view, - var_idx, delta, cstr_idx, cstr_coeff, @@ -649,6 +649,269 @@ static inline std::pair compute_score(fj_cpu_climber_t::max()}; + + bool operator>(const two_opt_move_t& other) const + { + if (score != other.score) return score > other.score; + if (age != other.age) return age < other.age; + if (first.var_idx != other.first.var_idx) return first.var_idx < other.first.var_idx; + return second.var_idx < other.second.var_idx; + } +}; + +// returns the combined score of a joint 2opt move +template +static fj_staged_score_t two_opt_compute_pair_score( + fj_cpu_climber_t& fj_cpu, i_t first, f_t first_delta, i_t second, f_t second_delta) +{ + auto& row_deltas = fj_cpu.two_opt_row_deltas; + row_deltas.clear(); + const fj_move_t endpoints[2] = {{first, first_delta}, {second, second_delta}}; + for (const auto& [var_idx, delta] : endpoints) { + const auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); + fj_cpu.nnz_processed_window += offset_end - offset_begin; + for (i_t i = offset_begin; i < offset_end; ++i) { + const i_t cstr_idx = fj_cpu.h_reverse_constraints[i]; + const f_t coeff = fj_cpu.h_reverse_coefficients[i]; + row_deltas.emplace_back(cstr_idx, coeff * delta); + } + } + // Brings the entries of a shared row next to each other + std::sort(row_deltas.begin(), row_deltas.end()); + + f_t base_feas_sum = 0; + f_t bonus_robust_sum = 0; + for (size_t pos = 0; pos < row_deltas.size();) { + const i_t cstr_idx = row_deltas[pos].first; + f_t lhs_delta = 0; + do { + lhs_delta += row_deltas[pos++].second; + } while (pos < row_deltas.size() && row_deltas[pos].first == cstr_idx); + + // The coefficients are already folded into lhs_delta, hence the unit coefficient + auto [cstr_base_feas, cstr_bonus_robust] = + feas_score_constraint(fj_cpu.view, + lhs_delta, + cstr_idx, + 1, + fj_cpu.h_cstr_lb[cstr_idx], + fj_cpu.h_cstr_ub[cstr_idx], + fj_cpu.h_lhs[cstr_idx], + fj_cpu.h_cstr_left_weights[cstr_idx], + fj_cpu.h_cstr_right_weights[cstr_idx]); + base_feas_sum += cstr_base_feas; + bonus_robust_sum += cstr_bonus_robust; + } + + const f_t obj_diff = + fj_cpu.h_obj_coeffs[first] * first_delta + fj_cpu.h_obj_coeffs[second] * second_delta; + f_t base_obj = 0; + if (obj_diff < 0) + base_obj = fj_cpu.h_objective_weight; + else if (obj_diff > 0) + base_obj = -fj_cpu.h_objective_weight; + + f_t bonus_breakthrough = 0; + bool old_obj_better = fj_cpu.h_incumbent_objective < fj_cpu.h_best_objective; + bool new_obj_better = fj_cpu.h_incumbent_objective + obj_diff < fj_cpu.h_best_objective; + if (!old_obj_better && new_obj_better) + bonus_breakthrough += fj_cpu.h_objective_weight; + else if (old_obj_better && !new_obj_better) + bonus_breakthrough -= fj_cpu.h_objective_weight; + + fj_staged_score_t score; + score.base = round(base_obj + base_feas_sum); + score.bonus = round(bonus_breakthrough + bonus_robust_sum); + return score; +} + +template +static void two_opt_add_partner(fj_cpu_climber_t& fj_cpu, + i_t first, + i_t var_idx, + f_t target) +{ + if (var_idx == first) return; + const f_t val = fj_cpu.h_assignment[var_idx].get(); + // A partner between two integers has no opposite value to swap to + if (!fj_cpu.view.pb.is_integer(val)) return; + const f_t delta = target - val; + // Already at the value we would move it to, so there is no compound move to make + if (fabs(delta) < 0.5) return; + if (!check_variable_within_bounds(fj_cpu, var_idx, target)) return; + if (tabu_check(fj_cpu, var_idx, delta, true)) return; + fj_cpu.two_opt_partners.emplace_back(var_idx, delta); +} + +/** + * @brief Fill fj_cpu.two_opt_partners with candidates to flip together with `first`. + * + * Preferred source is the probing cache: it recorded, for each probed variable and value, the + * bounds propagation implies on every other variable. An implied bound pinning a binary to a value + * names both the partner and the value it has to take once `first` moves, so a pair moving in the + * same direction is reached as naturally as a swap. The + * variables sharing a row with it are used as fallback. + */ +template +static void two_opt_collect_partners(fj_cpu_climber_t& fj_cpu, + i_t first, + f_t first_delta, + size_t max_partners) +{ + auto& partners = fj_cpu.two_opt_partners; + const i_t n_variables = fj_cpu.view.pb.n_variables; + partners.clear(); + cuopt_assert(fj_cpu.h_is_binary_variable[first], "2-opt is only defined for binaries"); + cuopt_assert( + fj_cpu.probing_cache == nullptr || fj_cpu.h_original_ids.size() == (size_t)n_variables, + "original id map does not cover every variable"); + cuopt_assert(fj_cpu.probing_cache == nullptr || + fj_cpu.h_reverse_original_ids.size() >= fj_cpu.h_original_ids.size(), + "reverse original id map smaller than the problem"); + + if (fj_cpu.probing_cache != nullptr) { + const auto& cache = fj_cpu.probing_cache->probing_cache; + const auto cached_probe = cache.find(fj_cpu.h_original_ids[first]); + if (cached_probe != cache.end()) { + const f_t new_val = fj_cpu.h_assignment[first].get() + first_delta; + i_t hit_interval = -1; + i_t unused_hit = -1; + for (i_t interval = 0; interval < 2; ++interval) { + const auto& entry = cached_probe->second[interval]; + if (entry.var_to_cached_bound_map.empty()) { continue; } + entry.val_interval.fill_cache_hits(interval, new_val, new_val, hit_interval, unused_hit); + } + if (hit_interval != -1) { + const auto& implications = cached_probe->second[hit_interval].var_to_cached_bound_map; + for (const auto& [probed_id, implied] : implications) { + if (partners.size() >= max_partners) break; + const i_t var_idx = fj_cpu.h_reverse_original_ids[probed_id]; + // -1 means presolve removed the variable after the probe recorded it + if (var_idx < 0) { continue; } + cuopt_assert(var_idx < n_variables, "implied variable out of range"); + if (!fj_cpu.h_is_binary_variable[var_idx]) { continue; } + if (!fj_cpu.view.pb.integer_equal(implied.lb, implied.ub)) { continue; } + two_opt_add_partner(fj_cpu, first, var_idx, round(implied.lb)); + } + } + } + } + + const auto& related = fj_cpu.h_related_variables; + const auto& related_offsets = fj_cpu.h_related_variables_offsets; + if (related_offsets.size() != (size_t)n_variables + 1) return; + const f_t swap_target = fj_cpu.h_assignment[first].get(); + const i_t related_begin = related_offsets[first]; + const i_t related_end = related_offsets[first + 1]; + for (i_t i = related_begin; i < related_end && partners.size() < max_partners; ++i) { + const i_t var_idx = related[i]; + if (fj_cpu.h_is_binary_variable[var_idx]) { + two_opt_add_partner(fj_cpu, first, var_idx, swap_target); + } + } +} + +// Look for binary 2opt moves at a local minimum. by definition no 1opt move can improve, but +// combined moves may especially in the case of set partitioning constraints / cliques. Use +// information from the probing cache to find potential good 2opt moves. +template +static two_opt_move_t find_two_opt_move(fj_cpu_climber_t& fj_cpu) +{ + CPUFJ_NVTX_RANGE("CPUFJ::find_two_opt_move"); + constexpr size_t max_obj_starts = 64; + constexpr size_t max_partners_per_var = 16; + + const auto& params = fj_cpu.settings.parameters; + const size_t max_target_rows = params.two_opt_max_rows; + const size_t max_first_vars = params.two_opt_max_row_vars; + const size_t max_pairs = params.two_opt_max_pairs; + + two_opt_move_t best; + + const bool partner_source_exists = + (fj_cpu.probing_cache != nullptr && !fj_cpu.probing_cache->probing_cache.empty()) || + (int64_t)fj_cpu.h_related_variables_offsets.size() == fj_cpu.view.pb.n_variables + 1; + + if (fj_cpu.n_binary_vars == 0 || !partner_source_exists) return best; + + auto& first_vars = fj_cpu.two_opt_first_vars; + first_vars.clear(); + + // target binvars in violated constraints for flips + if (!fj_cpu.violated_constraints.empty()) { + cuopt_assert(fj_cpu.h_binrow_offsets.size() == fj_cpu.view.pb.n_constraints + 1, + "binary row table missing"); + auto& target_cstrs = fj_cpu.two_opt_target_cstrs; + target_cstrs.clear(); + std::sample(fj_cpu.violated_constraints.begin(), + fj_cpu.violated_constraints.end(), + std::back_inserter(target_cstrs), + max_target_rows, + fj_cpu.rng); + for (i_t cstr_idx : target_cstrs) { + const i_t bin_begin = fj_cpu.h_binrow_offsets[cstr_idx]; + const i_t bin_end = fj_cpu.h_binrow_offsets[cstr_idx + 1]; + for (i_t i = bin_begin; i < bin_end && first_vars.size() < max_first_vars; ++i) { + first_vars.push_back(fj_cpu.h_binrow_vars[i].get()); + } + } + } else { + // target objective-bearing binary vars in satisfied constraints + std::sample(fj_cpu.h_objective_vars.underlying().begin(), + fj_cpu.h_objective_vars.underlying().end(), + std::back_inserter(first_vars), + max_obj_starts, + fj_cpu.rng); + first_vars.erase(std::remove_if(first_vars.begin(), + first_vars.end(), + [&](i_t var_idx) { + if (!fj_cpu.h_is_binary_variable[var_idx]) return true; + const f_t delta = + round(1 - 2 * fj_cpu.h_assignment[var_idx].get()); + return fj_cpu.h_obj_coeffs[var_idx] * delta >= 0; + }), + first_vars.end()); + } + std::shuffle(first_vars.begin(), first_vars.end(), fj_cpu.rng); + + const i_t nnz_at_entry = fj_cpu.nnz_processed_window; + size_t pairs_scored = 0; + // find a (first, second) pair for the 2opt + for (i_t first : first_vars) { + if (pairs_scored >= max_pairs) break; + if (fj_cpu.nnz_processed_window - nnz_at_entry > fj_cpu.nnz_samples) break; + const f_t first_val = fj_cpu.h_assignment[first].get(); + if (!fj_cpu.view.pb.is_integer(first_val)) continue; + const f_t first_delta = round(1 - 2 * first_val); + if (tabu_check(fj_cpu, first, first_delta, true)) continue; + if (!check_variable_within_bounds(fj_cpu, first, first_val + first_delta)) continue; + const i_t first_touch = std::max(fj_cpu.h_tabu_lastinc[first], fj_cpu.h_tabu_lastdec[first]); + + // look for potential other binary vars to flip alongside the first var + two_opt_collect_partners(fj_cpu, first, first_delta, max_partners_per_var); + for (const auto& [second, second_delta] : fj_cpu.two_opt_partners) { + const i_t second_touch = + std::max(fj_cpu.h_tabu_lastinc[second], fj_cpu.h_tabu_lastdec[second]); + two_opt_move_t cand; + cand.first = {first, first_delta}; + cand.second = {second, second_delta}; + cand.score = two_opt_compute_pair_score(fj_cpu, first, first_delta, second, second_delta); + cand.age = std::max(first_touch, second_touch); + if (cand > best) { best = cand; } + ++pairs_scored; + + if (pairs_scored >= max_pairs) return best; + if (fj_cpu.nnz_processed_window - nnz_at_entry > fj_cpu.nnz_samples) return best; + } + } + return best; +} + template static void smooth_weights(fj_cpu_climber_t& fj_cpu) { @@ -1030,7 +1293,7 @@ static thrust::tuple find_mtm_move_viol( fj_cpu.violated_constraints.end(), std::back_inserter(sampled_cstrs), sample_size, - std::mt19937(fj_cpu.settings.seed + fj_cpu.iterations)); + fj_cpu.rng); return find_mtm_move(fj_cpu, sampled_cstrs, localmin); } @@ -1048,7 +1311,7 @@ static thrust::tuple find_mtm_move_sat( fj_cpu.satisfied_constraints.end(), std::back_inserter(sampled_cstrs), sample_size, - std::mt19937(fj_cpu.settings.seed + fj_cpu.iterations)); + fj_cpu.rng); return find_mtm_move(fj_cpu, sampled_cstrs); } @@ -1216,7 +1479,7 @@ static void perturb(fj_cpu_climber_t& fj_cpu) fj_cpu.h_objective_vars.end(), std::back_inserter(sampled_vars), 2, - std::mt19937(fj_cpu.settings.seed + fj_cpu.iterations)); + fj_cpu.rng); raft::random::PCGenerator rng(fj_cpu.settings.seed + fj_cpu.iterations, 0, 0); for (auto var_idx : sampled_vars) { @@ -1243,7 +1506,8 @@ static void init_fj_cpu(fj_cpu_climber_t& fj_cpu, solution_t& solution, const std::vector& left_weights, const std::vector& right_weights, - f_t objective_weight) + f_t objective_weight, + const probing_cache_t* probing_cache) { auto& problem = *solution.problem_ptr; auto handle_ptr = solution.handle_ptr; @@ -1272,6 +1536,13 @@ static void init_fj_cpu(fj_cpu_climber_t& fj_cpu, fj_cpu.h_is_binary_variable = cuopt::host_copy(problem.is_binary_variable, handle_ptr->get_stream()); fj_cpu.h_binary_indices = cuopt::host_copy(problem.binary_indices, handle_ptr->get_stream()); + fj_cpu.h_related_variables = + cuopt::host_copy(problem.related_variables, handle_ptr->get_stream()); + fj_cpu.h_related_variables_offsets = + cuopt::host_copy(problem.related_variables_offsets, handle_ptr->get_stream()); + fj_cpu.probing_cache = probing_cache; + fj_cpu.h_original_ids = problem.original_ids; + fj_cpu.h_reverse_original_ids = problem.reverse_original_ids; fj_cpu.h_cstr_left_weights = left_weights; fj_cpu.h_cstr_right_weights = right_weights; @@ -1408,6 +1679,19 @@ void finalize_fj_cpu_host_initialization( } } + // precompute the binvars-pre-row tables for 2opt + fj_cpu.h_binrow_offsets.resize(n_constraints + 1); + fj_cpu.h_binrow_vars.clear(); + for (i_t cstr_idx = 0; cstr_idx < n_constraints; ++cstr_idx) { + fj_cpu.h_binrow_offsets[cstr_idx] = fj_cpu.h_binrow_vars.size(); + auto [offset_begin, offset_end] = range_for_constraint(fj_cpu, cstr_idx); + for (i_t i = offset_begin; i < offset_end; ++i) { + const i_t var_idx = fj_cpu.h_variables[i]; + if (fj_cpu.h_is_binary_variable[var_idx]) { fj_cpu.h_binrow_vars.push_back(var_idx); } + } + } + fj_cpu.h_binrow_offsets[n_constraints] = fj_cpu.h_binrow_vars.size(); + fj_cpu.flip_move_computed.resize(n_variables, false); fj_cpu.var_bitmap.resize(n_variables, false); fj_cpu.iter_mtm_vars.reserve(n_variables); @@ -1584,6 +1868,7 @@ std::unique_ptr> fj_t::create_cpu_climber( const std::vector& right_weights, f_t objective_weight, std::atomic& preemption_flag, + const probing_cache_t* probing_cache, fj_settings_t settings, bool randomize_params) { @@ -1592,7 +1877,7 @@ std::unique_ptr> fj_t::create_cpu_climber( auto fj_cpu = std::make_unique>(preemption_flag); // Initialize fj_cpu with all the data - init_fj_cpu(*fj_cpu, solution, left_weights, right_weights, objective_weight); + init_fj_cpu(*fj_cpu, solution, left_weights, right_weights, objective_weight, probing_cache); fj_cpu->settings = settings; if (randomize_params) { auto rng = std::mt19937(cuopt::seed_generator::get_seed()); @@ -1613,6 +1898,8 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w auto time_limit = std::chrono::milliseconds(static_cast(std::floor(in_time_limit * 1000.0))); auto loop_time_start = std::chrono::high_resolution_clock::now(); + fj_cpu->rng.seed(fj_cpu->settings.seed); + // Initialize feature tracking fj_cpu->last_feature_log_time = loop_start; fj_cpu->prev_best_objective = fj_cpu->h_best_objective; @@ -1691,11 +1978,20 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w for (size_t i = 0; i < fj_cpu->cached_mtm_moves.size(); i++) fj_cpu->cached_mtm_moves[i].first = 0; } - thrust::tie(move, score) = - find_mtm_move_viol(*fj_cpu, 1, true); // pick a single random violated constraint - i_t var_idx = move.var_idx >= 0 ? move.var_idx : 0; - f_t delta = move.var_idx >= 0 ? move.value : 0; - apply_move(*fj_cpu, var_idx, delta, true); + + two_opt_move_t two_opt_move; + if (!should_perturb) two_opt_move = find_two_opt_move(*fj_cpu); + if (two_opt_move.score > fj_staged_score_t::zero()) { + apply_move(*fj_cpu, two_opt_move.first.var_idx, two_opt_move.first.value, true); + apply_move(*fj_cpu, two_opt_move.second.var_idx, two_opt_move.second.value, true); + fj_cpu->n_mtm_viol_moves_window += 2; + } else { + thrust::tie(move, score) = + find_mtm_move_viol(*fj_cpu, 1, true); // pick a single random violated constraint + i_t var_idx = move.var_idx >= 0 ? move.var_idx : 0; + f_t delta = move.var_idx >= 0 ? move.value : 0; + apply_move(*fj_cpu, var_idx, delta, true); + } ++local_mins; ++fj_cpu->n_local_minima_window; } @@ -1784,7 +2080,9 @@ std::unique_ptr> init_fj_cpu_standalone( auto fj_cpu = std::make_unique>(preemption_flag); std::vector default_weights(problem.n_constraints, 1.0); - init_fj_cpu(*fj_cpu, solution, default_weights, default_weights, 0.0); + // Early CPUFJ runs while presolve is still probing, so there are no implications to hand it + const probing_cache_t* no_implications = nullptr; + init_fj_cpu(*fj_cpu, solution, default_weights, default_weights, 0.0, no_implications); fj_cpu->settings = settings; fj_cpu->settings.seed = cuopt::seed_generator::get_seed(); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 718c89615d..411b4083f7 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -20,6 +21,9 @@ namespace cuopt::mathematical_optimization::mip { +template +class probing_cache_t; + // NOTE: this seems an easy pick for reflection/xmacros once this is available (C++26?) // Maintaining a single source of truth for all members would be nice template @@ -44,6 +48,12 @@ struct fj_cpu_climber_t { ADD_INSTRUMENTED(h_is_binary_variable), ADD_INSTRUMENTED(h_objective_vars), ADD_INSTRUMENTED(h_binary_indices), + ADD_INSTRUMENTED(h_related_variables), + ADD_INSTRUMENTED(h_related_variables_offsets), + ADD_INSTRUMENTED(h_binrow_offsets), + ADD_INSTRUMENTED(h_binrow_vars), + ADD_INSTRUMENTED(h_original_ids), + ADD_INSTRUMENTED(h_reverse_original_ids), ADD_INSTRUMENTED(h_tabu_nodec_until), ADD_INSTRUMENTED(h_tabu_noinc_until), ADD_INSTRUMENTED(h_tabu_lastdec), @@ -67,6 +77,7 @@ struct fj_cpu_climber_t { problem_t* pb_ptr; fj_settings_t settings; + std::mt19937 rng; typename fj_t::climber_data_t::view_t view; // Host copies of device data as struct members ins_vector h_reverse_coefficients; @@ -83,6 +94,16 @@ struct fj_cpu_climber_t { ins_vector h_is_binary_variable; ins_vector h_objective_vars; ins_vector h_binary_indices; + ins_vector h_related_variables; + ins_vector h_related_variables_offsets; + + // precompute the binary variables per row for bin 2opt + ins_vector h_binrow_offsets; + ins_vector h_binrow_vars; + const probing_cache_t* probing_cache{nullptr}; + // Probing cache keys are pre-trivial-presolve variable ids; these translate to and from them + ins_vector h_original_ids; + ins_vector h_reverse_original_ids; ins_vector h_tabu_nodec_until; ins_vector h_tabu_noinc_until; @@ -134,6 +155,12 @@ struct fj_cpu_climber_t { std::vector var_bitmap; ins_vector iter_mtm_vars; + // Scratch reused by the binary 2-opt search, which runs at every local minimum + std::vector two_opt_target_cstrs; + std::vector two_opt_first_vars; + std::vector> two_opt_partners; + std::vector> two_opt_row_deltas; + i_t mtm_viol_samples{25}; i_t mtm_sat_samples{15}; i_t nnz_samples{50000}; diff --git a/cpp/src/mip_heuristics/local_search/local_search.cu b/cpp/src/mip_heuristics/local_search/local_search.cu index 75c4185949..23edf555cd 100644 --- a/cpp/src/mip_heuristics/local_search/local_search.cu +++ b/cpp/src/mip_heuristics/local_search/local_search.cu @@ -71,6 +71,7 @@ void local_search_t::start_cpufj_scratch_threads(population_t 0); @@ -117,8 +118,12 @@ void local_search_t::start_cpufj_lptopt_scratch_threads( solution_lp.copy_new_assignment( host_copy(lp_optimal_solution, context.problem_ptr->handle_ptr->get_stream())); solution_lp.round_random_nearest(500); - scratch_cpu_fj_on_lp_opt = fj.create_cpu_climber( - solution_lp, default_weights, default_weights, 0., context.preempt_heuristic_solver_); + scratch_cpu_fj_on_lp_opt = fj.create_cpu_climber(solution_lp, + default_weights, + default_weights, + 0., + context.preempt_heuristic_solver_, + &constraint_prop.bounds_update.probing_cache); scratch_cpu_fj_on_lp_opt->log_prefix = "******* scratch on LP optimal: "; scratch_cpu_fj_on_lp_opt->improvement_callback = [this, &population](f_t obj, const std::vector& h_vec, double /*work_units*/) { @@ -145,8 +150,11 @@ void local_search_t::stop_cpufj_scratch_threads() { if (omp_get_num_threads() < CUOPT_MIP_FJ_REQUIRED_THREAD_COUNT) return; + for (auto& cpu_fj : scratch_cpu_fj) { + cuopt_assert(cpu_fj != nullptr, "scratch climbers must have been created"); + cpu_fj->halted = true; + } for (size_t i = 0; i < scratch_cpu_fj.size(); ++i) { - scratch_cpu_fj[i]->halted = true; #pragma omp taskwait depend(in : *scratch_cpu_fj[i]) // Wait for each scratch CPU FJ task to finish } @@ -183,6 +191,7 @@ void local_search_t::start_cpufj_deterministic(mip::branch_and_bound_t default_weights, 0., context.preempt_heuristic_solver_, + &constraint_prop.bounds_update.probing_cache, fj_settings_t{}, /*randomize=*/true); @@ -258,6 +267,7 @@ bool local_search_t::do_fj_solve(solution_t& solution, h_weights, h_objective_weight, context.preempt_heuristic_solver_, + &constraint_prop.bounds_update.probing_cache, fj_settings_t{}, true); } diff --git a/cpp/src/mip_heuristics/presolve/probing_cache.cuh b/cpp/src/mip_heuristics/presolve/probing_cache.cuh index 928b479533..94d8af98ca 100644 --- a/cpp/src/mip_heuristics/presolve/probing_cache.cuh +++ b/cpp/src/mip_heuristics/presolve/probing_cache.cuh @@ -44,7 +44,7 @@ struct val_interval_t { f_t first_probe, f_t second_probe, i_t& hit_interval_for_first_probe, - i_t& hit_interval_for_second_probe) + i_t& hit_interval_for_second_probe) const { if (interval_type == interval_type_t::EQUALS) { if (val == first_probe) { hit_interval_for_first_probe = interval; } From ad98d0df56f62aead76cb5c975ee0d5d43d3bdc1 Mon Sep 17 00:00:00 2001 From: "Nicolas L. Guidotti" Date: Mon, 24 Aug 2026 13:33:10 +0200 Subject: [PATCH 06/78] Revert changes to PCG (#1762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert changes to the PCG random number generator introduced in PR #1608. Note that these generators are carefully constructed and small changes can affect the quality of the random number generated. Added a skill to avoid agents to modify this file. Authors: - Nicolas L. Guidotti (https://github.com/nguidotti) Approvers: - Akif ÇÖRDÜK (https://github.com/akifcorduk) - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv) - Chris Maes (https://github.com/chris-maes) URL: https://github.com/NVIDIA/cuopt/pull/1762 --- cpp/src/utilities/pcgenerator.hpp | 2 +- skills/cuopt-developer/SKILL.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cpp/src/utilities/pcgenerator.hpp b/cpp/src/utilities/pcgenerator.hpp index 5b4a226fce..021c950ce7 100644 --- a/cpp/src/utilities/pcgenerator.hpp +++ b/cpp/src/utilities/pcgenerator.hpp @@ -85,7 +85,7 @@ class pcgenerator_t { state = oldstate * 6364136223846793005ULL + stream; uint32_t xorshifted = ((oldstate >> 18u) ^ oldstate) >> 27u; uint32_t rot = oldstate >> 59u; - ret = (xorshifted >> rot) | (xorshifted << ((32u - rot) & 31u)); + ret = (xorshifted >> rot) | (xorshifted << ((-rot) & 31u)); return ret; } diff --git a/skills/cuopt-developer/SKILL.md b/skills/cuopt-developer/SKILL.md index 9d3bdb2fe4..56a97c6585 100644 --- a/skills/cuopt-developer/SKILL.md +++ b/skills/cuopt-developer/SKILL.md @@ -233,6 +233,20 @@ Treat `#pragma omp task if(...) firstprivate(...)` with a non-trivial C++ captur When diagnosing OpenMP-only failures, test compiler/runtime pairs separately. A Clang + libomp pass exercises the `__kmpc_*` ABI and does not cover GCC + libomp's `GOMP_*` path; reduce suspicious cases to a direct runtime-ABI probe before attributing them to solver logic. +## PCG random number generator + +`cpp/src/utilities/pcgenerator.hpp` (`cuopt::pcgenerator_t`) is copied from RAFT's `PCGenerator` (`raft/random/detail/rng_device.cuh`), duplicated only because the RAFT header pulls in CUDA and therefore cannot be included from a `.cpp`. **Treat the generator core as frozen.** Do not "clean it up", modernise it, or swap it for ``: reproducibility under `settings.random_seed` and `settings.deterministic` holds only while the byte-for-byte output sequence is preserved, and the CPU copy must keep producing the same stream as the GPU one. Adding a *new* helper that consumes `next_u32()`/`next_double()` is fine; changing how those values are produced is not. + +Each of the following looks like a defect or an obvious simplification, and is neither. Leave them alone: + +- `stream = (subsequence << 1u) | 1u` in `set_seed`. A 2^64 LCG has full period only when its increment is odd — the `| 1u` is what guarantees that — and the `<< 1u` is why two subsequences must differ in their **low 63 bits** to get independent streams. +- The two `next(discard)` warm-up calls straddling `state += seed`. This is PCG's canonical seeding sequence, and it is what decorrelates *adjacent* seeds — which is exactly how cuOpt seeds workers (`settings.random_seed + pcgenerator_t::default_seed + rng_offset + worker_id`, `branch_and_bound/worker.hpp`). Collapsing it to `state = seed` makes neighbouring workers draw near-identical prefixes. +- The output permutation in `next_u32` (`>> 18u`, `^`, `>> 27u`, `rot = oldstate >> 59u`, `(-rot) & 31u`) — the PCG-XSH-RR constants for a 64→32-bit output. In particular `(-rot) & 31u` relies on unsigned wraparound; rewriting it as `32 - rot` is a shift by 32, i.e. undefined behaviour, whenever `rot == 0`. +- The multiplier `6364136223846793005ULL`, which appears in both `next_u32` and as `h` in `skipahead`. `skipahead` is the closed-form LCG jump (Brown's arbitrary-stride method) and matches N calls to `next_u32` only while the two constants are identical. +- The `>> 8` / `>> 11` in `next_float` / `next_double`. They yield exactly 24 and 53 mantissa bits, so the result lies in `[0, 1)`. Dividing by `UINT32_MAX` instead makes `1.0` reachable and breaks every `rng.next_double() * n` used as an index. +- The sign-bit masks in `next_i32` / `next_i64` (`& 0x7fffffff…`); callers rely on the results being non-negative. +- `uniform()`'s floating-point scaling and `shuffle()`'s Fisher-Yates order. `uniform()`'s slight bias is documented and accepted; substituting a modulo or `std::uniform_int_distribution` changes every sampled sequence. + ## Troubleshooting & CI For build/test pitfalls (Cython rebuild, OOM, CUDA driver mismatch, missing `nvcc`) and CI failure diagnostics (style checks, DCO failures, dependency drift), see [references/troubleshooting.md](references/troubleshooting.md). From d8125105833aead749d1abaeac73af1afe8f4cab Mon Sep 17 00:00:00 2001 From: Yuwen Chen <37250191+yuwenchen95@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:39:20 +0200 Subject: [PATCH 07/78] Reuse existing cuOptGetProblemIntAttribute to simplify code in c API (#1769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Several standalone C API getters (`cuOptGetNumVariables`, `cuOptGetObjectiveSense`, `cuOptGetObjectiveOffset`, `cuOptGetObjectiveCoefficients`, `cuOptGetNumNonZeros`) duplicated logic that already exists in `cuOptGetProblemIntAttribute` / `cuOptGetProblemFloatAttribute` / `cuOptGetProblemFloatArrayAttribute` — same null checks, same single-field access, maintained in two places. Rewrote each as a thin wrapper delegating to the corresponding attribute call, so there's a single source of truth per attribute and the implementations can't drift apart. Behavior is unchanged: the delegated-to functions already perform the same null/argument validation, so error codes and return values are identical to before. `cuOptGetNumConstraints` had the same duplication and was already cleaned up the same way in #1761, along with other bug fixs. ## Issue ## Checklist - [ ] I am familiar with the [Contributing Guidelines](https://github.com/NVIDIA/cuopt/blob/HEAD/CONTRIBUTING.md). - Testing - [ ] New or existing tests cover these changes - [ ] Added tests - [ ] Created an issue to follow-up - [x] NA - Documentation - [ ] The documentation is up to date with these changes - [ ] Added new documentation - [x] NA Signed-off-by: yuwenchen95 --- cpp/src/pdlp/cuopt_c.cpp | 44 ++++++++-------------------------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index d1b6d3ac59..a14d1bd2a8 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -736,61 +736,35 @@ cuopt_int_t cuOptGetNumConstraints(cuOptOptimizationProblem problem, cuopt_int_t cuOptGetNumVariables(cuOptOptimizationProblem problem, cuopt_int_t* num_variables_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (num_variables_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - *num_variables_ptr = problem_and_stream_view->get_problem()->get_n_variables(); - return CUOPT_SUCCESS; + return cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_VARIABLES, num_variables_ptr); } cuopt_int_t cuOptGetObjectiveSense(cuOptOptimizationProblem problem, cuopt_int_t* objective_sense_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (objective_sense_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - *objective_sense_ptr = - problem_and_stream_view->get_problem()->get_sense() ? CUOPT_MAXIMIZE : CUOPT_MINIMIZE; - return CUOPT_SUCCESS; + return cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_OBJECTIVE_SENSE, objective_sense_ptr); } cuopt_int_t cuOptGetObjectiveOffset(cuOptOptimizationProblem problem, cuopt_float_t* objective_offset_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (objective_offset_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - *objective_offset_ptr = problem_and_stream_view->get_problem()->get_objective_offset(); - return CUOPT_SUCCESS; + return cuOptGetProblemFloatAttribute(problem, CUOPT_ATTR_OBJECTIVE_OFFSET, objective_offset_ptr); } cuopt_int_t cuOptGetObjectiveCoefficients(cuOptOptimizationProblem problem, cuopt_float_t* objective_coefficients_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (objective_coefficients_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - - cuopt_int_t size = problem_and_stream_view->get_problem()->get_n_variables(); - problem_and_stream_view->get_problem()->copy_objective_coefficients_to_host( - objective_coefficients_ptr, size); - - return CUOPT_SUCCESS; + cuopt_int_t size = 0; + cuopt_int_t status = cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_VARIABLES, &size); + if (status != CUOPT_SUCCESS) { return status; } + return cuOptGetProblemFloatArrayAttribute( + problem, CUOPT_ARRAY_ATTR_OBJECTIVE_COEFFICIENTS, objective_coefficients_ptr, size); } cuopt_int_t cuOptGetNumNonZeros(cuOptOptimizationProblem problem, cuopt_int_t* num_non_zero_elements_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (num_non_zero_elements_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - *num_non_zero_elements_ptr = problem_and_stream_view->get_problem()->get_nnz(); - return CUOPT_SUCCESS; + return cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_NONZEROS, num_non_zero_elements_ptr); } cuopt_int_t cuOptGetConstraintMatrix(cuOptOptimizationProblem problem, From 8309a4b4daa7274e2381251f7646903f2a2bd135 Mon Sep 17 00:00:00 2001 From: Yuwen Chen <37250191+yuwenchen95@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:47:34 +0200 Subject: [PATCH 08/78] Fix QCQP dual solution overrun and constraint count reporting (#1761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `cuOptGetDualSolution`/`cuOptGetReducedCosts` wrote more elements than `cuOptGetNumConstraints` documented whenever the problem had quadratic constraints, overrunning a caller-sized buffer. Root cause: quadratic constraints are internally reformulated into second-order cones for the barrier solver, which grows the row count beyond the original constraint count, but the returned dual vector was never resized back down before being copied into the caller's buffer — and dual recovery for QCQP isn't supported yet regardless. Fix: dual solution / reduced cost access for QCQP now returns an error instead of writing invalid or wrong-sized data, consistently across all three surfaces: - **C API**: `cuOptGetDualSolution`/`cuOptGetReducedCosts` return `CUOPT_INVALID_ARGUMENT` (matching the existing MIP-solution pattern). - **Python**: `Solution.get_dual_solution()`/`get_reduced_cost()` raise `AttributeError` only when explicitly called — the solve itself still succeeds normally. - **gRPC**: `dual_solution`/`reduced_cost` are simply omitted from the response payload (same mechanism already used for other optional fields), so a QCQP solve still succeeds end-to-end. In all cases, a normal solve (primal solution, objective, termination status, etc.) is unaffected for QCQP problems — only explicit dual/reduced-cost access is gated. Also fixes the underlying ambiguity flagged in the issue discussion: `cuOptGetNumConstraints` / `CUOPT_ATTR_NUM_CONSTRAINTS` previously reported linear constraints only; they now report linear + quadratic constraints combined. Added a new `CUOPT_ATTR_NUM_QUADRATIC_CONSTRAINTS` attribute so callers can still recover the quadratic-only count. ## Issue closes #1751 ## Checklist - [x] I am familiar with the [Contributing Guidelines](https://github.com/NVIDIA/cuopt/blob/HEAD/CONTRIBUTING.md). - Testing - [ ] New or existing tests cover these changes - [x] Added tests - [ ] Created an issue to follow-up - [ ] NA - Documentation - [ ] The documentation is up to date with these changes - [x] Added new documentation - [ ] NA --------- Signed-off-by: yuwenchen95 --- .../mathematical_optimization/constants.h | 2 + .../cuopt/mathematical_optimization/cuopt_c.h | 11 +- cpp/src/pdlp/cuopt_c.cpp | 22 ++- cpp/src/pdlp/solve.cu | 6 + .../c_api_tests/c_api_test.c | 187 ++++++++++++++++++ .../c_api_tests/c_api_tests.cpp | 5 + .../c_api_tests/c_api_tests.h | 1 + 7 files changed, 221 insertions(+), 13 deletions(-) diff --git a/cpp/include/cuopt/mathematical_optimization/constants.h b/cpp/include/cuopt/mathematical_optimization/constants.h index 7128fcf0e9..0ac8c01875 100644 --- a/cpp/include/cuopt/mathematical_optimization/constants.h +++ b/cpp/include/cuopt/mathematical_optimization/constants.h @@ -257,6 +257,8 @@ #define CUOPT_ATTR_IS_MIP 8 #define CUOPT_ATTR_HAS_QUADRATIC_OBJECTIVE 9 #define CUOPT_ATTR_HAS_QUADRATIC_CONSTRAINTS 10 +#define CUOPT_ATTR_NUM_LINEAR_CONSTRAINTS 11 +#define CUOPT_ATTR_NUM_QUADRATIC_CONSTRAINTS 12 /* @brief Numeric/char array problem attribute selectors * (see cuOptGetProblem{Float,Char}ArrayAttribute; sized by num_variables / num_constraints). diff --git a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h index c133ab8f8e..edb2188503 100644 --- a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h +++ b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h @@ -1174,11 +1174,12 @@ cuopt_int_t cuOptGetSolutionFloatAttribute(cuOptSolution solution, * * These accessors use copy-out semantics: the caller allocates the output buffer and cuOpt copies * values into it. Array attributes are sized by the problem dimensions: variable-indexed arrays - * have num_variables entries and constraint-indexed arrays have num_constraints entries (see - * cuOptGetProblemIntAttribute). The sole exception to copy-out is the - * string-array getter, which fills a caller-provided array of pointers with borrowed pointers into - * cuOpt-owned string storage; those pointers are valid until the problem is modified or destroyed - * and must not be freed. + * have num_variables entries and constraint-indexed arrays (CUOPT_ARRAY_ATTR_CONSTRAINT_*) have + * one entry per LINEAR constraint only — i.e. CUOPT_ATTR_NUM_LINEAR_CONSTRAINTS. + * The sole exception to + * copy-out is the string-array getter, which fills a caller-provided array of pointers with + * borrowed pointers into cuOpt-owned string storage; those pointers are valid until the problem + * is modified or destroyed and must not be freed. * * The constraint matrix is retrieved via cuOptGetConstraintMatrix (CSR) / * cuOptGetConstraintMatrixCSC. diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index a14d1bd2a8..05e972ad1b 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -125,7 +125,9 @@ bool is_int_attribute(cuopt_int_t attribute) case CUOPT_ATTR_PROBLEM_CATEGORY: case CUOPT_ATTR_IS_MIP: case CUOPT_ATTR_HAS_QUADRATIC_OBJECTIVE: - case CUOPT_ATTR_HAS_QUADRATIC_CONSTRAINTS: return true; + case CUOPT_ATTR_HAS_QUADRATIC_CONSTRAINTS: + case CUOPT_ATTR_NUM_LINEAR_CONSTRAINTS: + case CUOPT_ATTR_NUM_QUADRATIC_CONSTRAINTS: return true; default: return false; } } @@ -726,12 +728,7 @@ void cuOptDestroyProblem(cuOptOptimizationProblem* problem_ptr) cuopt_int_t cuOptGetNumConstraints(cuOptOptimizationProblem problem, cuopt_int_t* num_constraints_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (num_constraints_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - *num_constraints_ptr = problem_and_stream_view->get_problem()->get_n_constraints(); - return CUOPT_SUCCESS; + return cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_CONSTRAINTS, num_constraints_ptr); } cuopt_int_t cuOptGetNumVariables(cuOptOptimizationProblem problem, cuopt_int_t* num_variables_ptr) @@ -1454,7 +1451,10 @@ cuopt_int_t cuOptGetProblemIntAttribute(cuOptOptimizationProblem problem, auto* iface = get_iface(problem); switch (attribute) { case CUOPT_ATTR_NUM_VARIABLES: *value_out = iface->get_n_variables(); return CUOPT_SUCCESS; - case CUOPT_ATTR_NUM_CONSTRAINTS: *value_out = iface->get_n_constraints(); return CUOPT_SUCCESS; + case CUOPT_ATTR_NUM_CONSTRAINTS: + *value_out = iface->get_n_constraints() + + static_cast(iface->get_quadratic_constraints().size()); + return CUOPT_SUCCESS; case CUOPT_ATTR_NUM_NONZEROS: *value_out = iface->get_nnz(); return CUOPT_SUCCESS; case CUOPT_ATTR_NUM_INTEGERS: *value_out = iface->get_n_integers(); return CUOPT_SUCCESS; case CUOPT_ATTR_OBJECTIVE_SENSE: @@ -1475,6 +1475,12 @@ cuopt_int_t cuOptGetProblemIntAttribute(cuOptOptimizationProblem problem, case CUOPT_ATTR_HAS_QUADRATIC_CONSTRAINTS: *value_out = iface->has_quadratic_constraints() ? 1 : 0; return CUOPT_SUCCESS; + case CUOPT_ATTR_NUM_LINEAR_CONSTRAINTS: + *value_out = iface->get_n_constraints(); + return CUOPT_SUCCESS; + case CUOPT_ATTR_NUM_QUADRATIC_CONSTRAINTS: + *value_out = static_cast(iface->get_quadratic_constraints().size()); + return CUOPT_SUCCESS; default: return CUOPT_INVALID_ARGUMENT; } } diff --git a/cpp/src/pdlp/solve.cu b/cpp/src/pdlp/solve.cu index 80b3da2c18..b8cb4aead8 100644 --- a/cpp/src/pdlp/solve.cu +++ b/cpp/src/pdlp/solve.cu @@ -1883,6 +1883,12 @@ optimization_problem_solution_t solve_qcqp( CUOPT_LOG_INFO("Dual variables for problems with quadratic constraints not returned."); const f_t nan_val = std::numeric_limits::quiet_NaN(); auto stream = op_problem.get_handle_ptr()->get_stream(); + // solve_qcqp() reformulates quadratic constraints into second-order cones, which grows + // the internal row/column count beyond the documented num_constraints/num_variables. + // Resize back down to the documented lengths. + solution.get_dual_solution().resize( + op_problem.get_n_constraints() + op_problem.get_quadratic_constraints().size(), stream); + solution.get_reduced_cost().resize(op_problem.get_n_variables(), stream); thrust::fill(rmm::exec_policy(stream), solution.get_dual_solution().begin(), solution.get_dual_solution().end(), 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 31bd18d0ef..16852b95ef 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 @@ -2504,6 +2504,193 @@ cuopt_int_t test_mip_solution_lp_methods() return status; } +/** + * Dual recovery + * for QCQP is not supported yet, so solve_qcqp() resizes the dual solution / reduced + * cost vectors down to the documented lengths and fills them with NaN (cuOpt's + * existing "value not computed" convention) rather than leaving them oversized. + */ +cuopt_int_t test_qcqp_solution_dual_methods() +{ + cuOptOptimizationProblem problem = NULL; + cuOptSolverSettings settings = NULL; + cuOptSolution solution = NULL; + cuopt_int_t status; + cuopt_int_t num_constraints; + cuopt_int_t num_linear; + cuopt_int_t num_quadratic; + int i; + + /* + * minimize t + * subject to + * t >= 0 (linear constraint) + * x1^2 + x2^2 - t^2 <= 0 (quadratic constraint) + * t >= -1, x1 >= 3, x2 >= 4 (variable bounds, not counted as constraints) + */ + cuopt_int_t num_linear_constraints = 1; + cuopt_int_t num_variables = 3; + + cuopt_float_t objective[] = {1.0, 0.0, 0.0}; + cuopt_int_t row_offsets[] = {0, 1}; + cuopt_int_t column_indices[] = {0}; + cuopt_float_t matrix_values[] = {1.0}; + char constraint_sense[] = {CUOPT_GREATER_THAN}; + cuopt_float_t rhs[] = {0.0}; + + cuopt_float_t lower_bounds[] = {-1.0, 3.0, 4.0}; + cuopt_float_t upper_bounds[] = {CUOPT_INFINITY, CUOPT_INFINITY, CUOPT_INFINITY}; + char variable_types[] = {CUOPT_CONTINUOUS, CUOPT_CONTINUOUS, CUOPT_CONTINUOUS}; + + cuopt_int_t qc_row[] = {0, 1, 2}; + cuopt_int_t qc_col[] = {0, 1, 2}; + cuopt_float_t qc_coeff[] = {-1.0, 1.0, 1.0}; + + /* Allocated to the documented sizes (queried below). */ + cuopt_float_t* dual_solution = NULL; + cuopt_float_t* reduced_costs = NULL; + + printf("Testing QCQP solution dual/reduced-cost methods...\n"); + + status = cuOptCreateProblem(num_linear_constraints, + num_variables, + CUOPT_MINIMIZE, + 0.0, + objective, + row_offsets, + column_indices, + matrix_values, + constraint_sense, + rhs, + lower_bounds, + upper_bounds, + variable_types, + &problem); + if (status != CUOPT_SUCCESS) { + printf("Error creating QCQP problem: %d\n", status); + goto DONE; + } + + status = cuOptAddQuadraticConstraint( + problem, 3, qc_row, qc_col, qc_coeff, 0, NULL, NULL, CUOPT_LESS_THAN, 0.0); + if (status != CUOPT_SUCCESS) { + printf("Error adding quadratic constraint: %d\n", status); + goto DONE; + } + + status = cuOptGetNumConstraints(problem, &num_constraints); + if (status != CUOPT_SUCCESS) { + printf("Error getting num constraints: %d\n", status); + goto DONE; + } + /* 1 linear + 1 quadratic constraint = 2 combined. */ + if (num_constraints != 2) { + printf("Error: expected 2 combined constraints, got %d\n", num_constraints); + status = -1; + goto DONE; + } + + status = cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_LINEAR_CONSTRAINTS, &num_linear); + if (status != CUOPT_SUCCESS) { + printf("Error getting num linear constraints: %d\n", status); + goto DONE; + } + if (num_linear != 1) { + printf("Error: expected 1 linear constraint, got %d\n", num_linear); + status = -1; + goto DONE; + } + + status = + cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_QUADRATIC_CONSTRAINTS, &num_quadratic); + if (status != CUOPT_SUCCESS) { + printf("Error getting num quadratic constraints: %d\n", status); + goto DONE; + } + if (num_quadratic != 1) { + printf("Error: expected 1 quadratic constraint, got %d\n", num_quadratic); + status = -1; + goto DONE; + } + + if (num_linear + num_quadratic != num_constraints) { + printf("Error: num_linear (%d) + num_quadratic (%d) != num_constraints (%d)\n", + num_linear, + num_quadratic, + num_constraints); + status = -1; + goto DONE; + } + + /* Sized to the documented lengths (num_constraints just queried above, num_variables + * used to build the problem), not a guessed capacity. */ + dual_solution = (cuopt_float_t*)malloc(num_constraints * sizeof(cuopt_float_t)); + reduced_costs = (cuopt_float_t*)malloc(num_variables * sizeof(cuopt_float_t)); + + status = cuOptCreateSolverSettings(&settings); + if (status != CUOPT_SUCCESS) { + printf("Error creating solver settings: %d\n", status); + goto DONE; + } + + status = cuOptSetIntegerParameter(settings, CUOPT_METHOD, CUOPT_METHOD_BARRIER); + if (status != CUOPT_SUCCESS) { + printf("Error setting barrier method: %d\n", status); + goto DONE; + } + + status = cuOptSolve(problem, settings, &solution); + if (status != CUOPT_SUCCESS) { + printf("Error solving QCQP: %d\n", status); + goto DONE; + } + + /* cuOptGetDualSolution on a QCQP solution should return CUOPT_SUCCESS and fill exactly + * the num_constraints-sized buffer with NaN (dual recovery not yet supported). A + * regression back to issue #1751 (writing more than num_constraints entries) would + * overrun this exactly-sized heap allocation. */ + status = cuOptGetDualSolution(solution, dual_solution); + if (status != CUOPT_SUCCESS) { + printf("Error: cuOptGetDualSolution on QCQP should return CUOPT_SUCCESS, got %d\n", status); + status = -1; + goto DONE; + } + for (i = 0; i < num_constraints; ++i) { + if (!isnan(dual_solution[i])) { + printf("Error: dual_solution[%d] expected NaN, got %g\n", i, dual_solution[i]); + status = -1; + goto DONE; + } + } + + /* cuOptGetReducedCosts on a QCQP solution should return CUOPT_SUCCESS and fill exactly + * the num_variables-sized buffer with NaN. */ + status = cuOptGetReducedCosts(solution, reduced_costs); + if (status != CUOPT_SUCCESS) { + printf("Error: cuOptGetReducedCosts on QCQP should return CUOPT_SUCCESS, got %d\n", status); + status = -1; + goto DONE; + } + for (i = 0; i < num_variables; ++i) { + if (!isnan(reduced_costs[i])) { + printf("Error: reduced_costs[%d] expected NaN, got %g\n", i, reduced_costs[i]); + status = -1; + goto DONE; + } + } + + printf("QCQP solution dual methods test passed\n"); + status = CUOPT_SUCCESS; + +DONE: + free(dual_solution); + free(reduced_costs); + cuOptDestroyProblem(&problem); + cuOptDestroySolverSettings(&settings); + cuOptDestroySolution(&solution); + return status; +} + /** * Test CPU-only execution with CUDA_VISIBLE_DEVICES="" and remote execution enabled. * This simulates a CPU host without GPU access. 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 cab37409fb..a54ad1aec6 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 @@ -483,6 +483,11 @@ TEST(c_api, lp_solution_mip_methods) { EXPECT_EQ(test_lp_solution_mip_methods(), TEST(c_api, mip_solution_lp_methods) { EXPECT_EQ(test_mip_solution_lp_methods(), CUOPT_SUCCESS); } +TEST(c_api, qcqp_solution_dual_methods) +{ + EXPECT_EQ(test_qcqp_solution_dual_methods(), CUOPT_SUCCESS); +} + // ============================================================================= // CPU-Only Execution Tests // These tests verify that cuOpt can run on a CPU-only host with remote execution 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 a8c3a1f4e4..a7242d1063 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 @@ -67,6 +67,7 @@ cuopt_int_t test_deterministic_bb(const char* filename, /* Tests for solution interface polymorphism (use inline problems, no file I/O) */ cuopt_int_t test_lp_solution_mip_methods(); cuopt_int_t test_mip_solution_lp_methods(); +cuopt_int_t test_qcqp_solution_dual_methods(); cuopt_int_t test_pdlp_precision_single(const char* filename, cuopt_int_t* termination_status_ptr, From 9ac6b6e6f6e710adb3feb626f7ce9763df8d091d Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu <42624703+ramakrishnap-nv@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:50:38 -0500 Subject: [PATCH 09/78] ci: stop nightly builds for release/26.08 (#1777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 26.08 is released and no further 26.08 releases are planned, so the nightly pipeline no longer needs to build it. Leaves `main`, which is 26.10 (`VERSION` = `26.10.00`). `release/26.10` is not added here because it has not been cut yet — the workflow resolves each matrix entry through the branches API before triggering, so an entry for a branch that does not exist would fail every night: ```bash export SHA=$(gh api -q '.commit.sha' "repos/nvidia/cuopt/branches/${CUOPT_BRANCH}") ``` It should be added once `release/26.10` exists. Authors: - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv) Approvers: - Trevor McKay (https://github.com/tmckayus) URL: https://github.com/NVIDIA/cuopt/pull/1777 --- .github/workflows/nightly.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 776de19705..bd04d21d87 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 name: Trigger Nightly cuOpt Pipeline @@ -23,7 +23,6 @@ jobs: matrix: cuopt_branch: - "main" - - "release/26.08" steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: From bae1d87a0e4d345420f7414f24b49400ebd0a669 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu <42624703+ramakrishnap-nv@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:08:23 -0500 Subject: [PATCH 10/78] fix: give each solver its own seed instead of a process-wide counter (#1717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `seed_generator::seed_` was a single process-wide counter. The two solvers seed it from unrelated inputs: ``` cpp/src/routing/problem/problem.cu:80 set_seed(num_requests, num_orders, num_orders) // problem geometry cpp/src/mip_heuristics/solve.cu:374 if (settings.seed >= 0) set_seed(settings.seed) // user settings ``` Sharing one counter means whichever solver runs last overwrites the other's seed, so solving a VRP and then a MIP in the same process silently discards the user's `settings.seed`. ## Change The seed now belongs to the solver that uses it. `seed_generator_t` is an instance held by `routing::problem_t` and `mip::problem_t`, each seeded from its own settings, and the process-wide `seed_generator` is removed. Routing gains `set_seed` / `get_seed` on `solver_settings_t`, following `mip_solver_settings_t` where `-1` means "derive it", so behaviour is unchanged when the user does not set one. Routing previously had no seed control at all despite being the component that overwrote the shared counter. All 45 call sites now draw from the owning problem — 13 in routing, 32 in the MIP heuristics. Two sites cannot reach a problem and take the seed explicitly rather than keeping a global: - `ejection_pool_t::random_shuffle(seed)` — the pool has no route back to a problem - the feasibility jump host-LP path falls back to the simplex settings' `random_seed`, which it already receives Seeds are handed out from a `thread_local` counter, rebased from the owning solver's base seed whenever that base changes, following the mechanism in Alice's earlier determinism work (`3e214a86`). A thread therefore walks the same sequence from a given base regardless of how workers interleave, so which seed a work item receives does not depend on scheduling — the property that matters for reproducibility across synchronisation points. The base itself lives on the problem, so the two concerns are separate: the base stops the two solvers overwriting each other's seed, the thread-local counter stops ordering from mattering. This also resolves the `// TODO: should be thread local?` that sat on the class. Because `base_seed_` is a plain `int64_t`, the class needs no atomic, no `mutable`, and no hand-written copy or assignment operators, and `problem_t` keeps its implicit copy and move. One inherited caveat, documented in the header: the thread-local state is a function-local static shared by all generators on a thread, rebased on a *change* of base. Two solvers configured with the same base seed and used from one thread continue a single sequence rather than each restarting. ## On the test `determinism_test.cu` called `seed_generator::set_seed(seed)` before each of three solves *even though it already set `settings.seed`* — a workaround for the global persisting between solves. Those three lines are gone; the test relies on `settings.seed` alone. ## Testing Clean build (CUDA 13.3, gcc 14.3) and `ctest`. `DeterministicBBTest` passes all four cases, including `reproducible_high_contention`, which is where a change in seed assignment under concurrent solves would surface. ## Follow-ups Python bindings for the routing seed, and routing-over-gRPC after #1597, which owns the routing entries in `field_registry.yaml`. ## History The class arrived in [rapidsai/cuopt#1270](https://github.com/rapidsai/cuopt/pull/1270) as a routing-local helper, where a static was a reasonable choice — it replaced `clock64()` seeding and was meant to be reachable from any kernel without plumbing. It became shared in [rapidsai/cuopt#2417](https://github.com/rapidsai/cuopt/pull/2417), which moved it from `routing/utilities` to `src/utilities` and is described purely as a file move; the "accessible throughout the code" premise was not revisited once a second solver used it. Authors: - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv) Approvers: - Bradley Dice (https://github.com/bdice) - Akif ÇÖRDÜK (https://github.com/akifcorduk) URL: https://github.com/NVIDIA/cuopt/pull/1717 --- cpp/include/cuopt/routing/solver_settings.hpp | 16 ++++ cpp/src/routing/adapters/adapted_generator.cu | 4 +- cpp/src/routing/adapters/adapted_modifier.cu | 8 +- cpp/src/routing/diversity/diverse_solver.hpp | 6 +- .../ges/compute_delivery_insertions.cuh | 4 +- cpp/src/routing/ges/eject_until_feasible.cu | 10 +- cpp/src/routing/ges/ejection_pool.cuh | 11 ++- cpp/src/routing/ges/execute_insertion.cu | 6 +- cpp/src/routing/ges/guided_ejection_search.cu | 8 +- .../lexicographic_search.cu | 2 +- .../ges/lexicographic_search/node_stack.cuh | 2 +- .../local_search/compute_insertions.cu | 12 +-- .../routing/local_search/fill_gpu_graph.cu | 6 +- .../local_search/permutation_helper.cuh | 4 +- cpp/src/routing/local_search/random_cross.cu | 6 +- .../routing/local_search/vrp/vrp_execute.cu | 2 +- cpp/src/routing/problem/problem.cu | 12 ++- cpp/src/routing/problem/problem.cuh | 8 +- cpp/src/routing/solver_settings.cu | 12 +++ cpp/src/routing/utilities/cuopt_utils.cuh | 4 +- cpp/src/routing/utilities/seed_generator.cuh | 91 +++++++++++++++++++ 21 files changed, 182 insertions(+), 52 deletions(-) create mode 100644 cpp/src/routing/utilities/seed_generator.cuh diff --git a/cpp/include/cuopt/routing/solver_settings.hpp b/cpp/include/cuopt/routing/solver_settings.hpp index 3aae7ff0ef..b68e774375 100644 --- a/cpp/include/cuopt/routing/solver_settings.hpp +++ b/cpp/include/cuopt/routing/solver_settings.hpp @@ -64,12 +64,27 @@ class solver_settings_t { */ void dump_best_results(const std::string& file_path, i_t interval); + /** + * @brief Set the random seed used by the routing solver. + * + * Controls the initial seed for random number generation. Use -1 to derive the seed + * from the problem, which is the default and reproduces a given problem run to run. + * + * @param[in] seed The seed, or -1 to derive it from the problem + */ + void set_seed(i_t seed); + /** * @brief Return set solving time * @return Solving time set in seconds */ f_t get_time_limit() const noexcept; + /** + * @brief Return the random seed, or -1 if it is derived from the problem + */ + i_t get_seed() const noexcept; + /** * @brief Return true if verbose mode is enabled */ @@ -93,6 +108,7 @@ class solver_settings_t { i_t dump_interval_{std::numeric_limits::max()}; bool dump_best_results_{false}; std::string best_result_file_name_; + i_t seed_{-1}; }; } // namespace CUOPT_EXPORT routing diff --git a/cpp/src/routing/adapters/adapted_generator.cu b/cpp/src/routing/adapters/adapted_generator.cu index 073be1ff1e..da4027add2 100644 --- a/cpp/src/routing/adapters/adapted_generator.cu +++ b/cpp/src/routing/adapters/adapted_generator.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 */ @@ -72,7 +72,7 @@ void generate_tsp_solution(adapted_sol_t& sol, for (i_t i = 0; i < (i_t)node_infos.size(); ++i) { node_infos[i] = sol.problem->get_node_info_of_node(i + sol.problem->order_info.depot_included_); } - std::mt19937 rng(seed_generator::get_seed()); + std::mt19937 rng(sol.problem->seed_gen.get_seed()); std::shuffle(node_infos.begin(), node_infos.end(), rng); std::vector>>> routes_to_add; routes_to_add.push_back({0, node_infos}); diff --git a/cpp/src/routing/adapters/adapted_modifier.cu b/cpp/src/routing/adapters/adapted_modifier.cu index b5f16ccbbd..4675581a6d 100644 --- a/cpp/src/routing/adapters/adapted_modifier.cu +++ b/cpp/src/routing/adapters/adapted_modifier.cu @@ -1,11 +1,11 @@ /* 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 */ -#include +#include #include "../diversity/helpers.hpp" #include "../ges/guided_ejection_search.cuh" @@ -77,7 +77,7 @@ void adapted_modifier_t::add_unserviced_request( auto gpu_weight = get_cuopt_cost(final_weight); resource.ls.set_active_weights(gpu_weight, std::numeric_limits::max()); adapted_solution.sol.populate_ep_with_unserved(resource.ges.EP); - resource.ges.EP.random_shuffle(); + resource.ges.EP.random_shuffle(adapted_solution.sol.problem_ptr->seed_gen.get_seed()); resource.ges.squeeze_all_ep(); adapted_solution.populate_host_data(); adapted_solution.check_device_host_coherence(); @@ -101,7 +101,7 @@ void adapted_modifier_t::add_selected_unserviced_requests( auto gpu_weight = get_cuopt_cost(final_weight); resource.ls.set_active_weights(gpu_weight, std::numeric_limits::max()); adapted_solution.sol.populate_ep_with_selected_unserved(resource.ges.EP, unserviced_nodes); - resource.ges.EP.random_shuffle(); + resource.ges.EP.random_shuffle(adapted_solution.sol.problem_ptr->seed_gen.get_seed()); resource.ges.squeeze_all_ep(); adapted_solution.populate_host_data(); adapted_solution.check_device_host_coherence(); diff --git a/cpp/src/routing/diversity/diverse_solver.hpp b/cpp/src/routing/diversity/diverse_solver.hpp index ccbb2c989e..67bf300084 100644 --- a/cpp/src/routing/diversity/diverse_solver.hpp +++ b/cpp/src/routing/diversity/diverse_solver.hpp @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -11,7 +11,7 @@ #include "helpers.hpp" #include "population.hpp" -#include +#include #include #include "../crossovers/dispose.hpp" #include "../crossovers/eax_recombiner.hpp" @@ -245,7 +245,7 @@ struct solve { temp_pair(solution{p_, pool_allocator_.sol_handles[0].get()}, solution{p_, pool_allocator_.sol_handles[0].get()}), f(file_name), - rng(seed_generator::get_seed()), + rng(p->seed_gen.get_seed()), timer(timer_), improvement_timer(timer_), perturbation_count(0) diff --git a/cpp/src/routing/ges/compute_delivery_insertions.cuh b/cpp/src/routing/ges/compute_delivery_insertions.cuh index 666919c902..8731743eb1 100644 --- a/cpp/src/routing/ges/compute_delivery_insertions.cuh +++ b/cpp/src/routing/ges/compute_delivery_insertions.cuh @@ -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 */ @@ -15,7 +15,7 @@ #include #include -#include +#include namespace cuopt { namespace routing { diff --git a/cpp/src/routing/ges/eject_until_feasible.cu b/cpp/src/routing/ges/eject_until_feasible.cu index 6de2380870..5a05bde062 100644 --- a/cpp/src/routing/ges/eject_until_feasible.cu +++ b/cpp/src/routing/ges/eject_until_feasible.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 */ @@ -365,8 +365,8 @@ void solution_t::eject_until_feasible(bool add_slack_to_sol) bool is_set = set_shmem_of_kernel(eject_until_feasible_kernel, sh_size); cuopt_assert(is_set, "Not enough shared memory on device for get_all_feasible_insertion!"); cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); - eject_until_feasible_kernel - <<>>(view(), add_slack_to_sol, seed_generator::get_seed()); + eject_until_feasible_kernel<<>>( + view(), add_slack_to_sol, problem_ptr->seed_gen.get_seed()); compute_cost(); global_runtime_checks(false, true, "eject_until_feasible"); } @@ -385,7 +385,7 @@ void solution_t::populate_ep_with_unserved( EP.index_ = ep_index_out.value(stream); stream.synchronize(); if (EP.size() > 1) { - thrust::default_random_engine g(seed_generator::get_seed()); + thrust::default_random_engine g(problem_ptr->seed_gen.get_seed()); thrust::shuffle( sol_handle->get_thrust_policy(), EP.stack_.begin(), EP.stack_.begin() + EP.size(), g); } @@ -405,7 +405,7 @@ void solution_t::populate_ep_with_selected_unserved( raft::device_span(unserviced_device.data(), unserviced_device.size()); populate_ep_with_selected_unserved_kernel<<<1, TPB, 0, stream>>>( - view(), unserviced_view, EP.view(), ep_index_out.data(), seed_generator::get_seed()); + view(), unserviced_view, EP.view(), ep_index_out.data(), problem_ptr->seed_gen.get_seed()); RAFT_CHECK_CUDA(stream); EP.index_ = ep_index_out.value(stream); stream.synchronize(); diff --git a/cpp/src/routing/ges/ejection_pool.cuh b/cpp/src/routing/ges/ejection_pool.cuh index 061b5ffb88..afd566f475 100644 --- a/cpp/src/routing/ges/ejection_pool.cuh +++ b/cpp/src/routing/ges/ejection_pool.cuh @@ -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 */ @@ -10,8 +10,8 @@ #include "../node/node.cuh" #include +#include #include -#include #include #include @@ -56,13 +56,14 @@ struct ejection_pool_t { void push_back_last() { ++index_; } - void random_shuffle() + // The seed is supplied by the caller: the pool has no route back to the problem + // that owns the seed source. + void random_shuffle(int64_t seed) { // replace with thrust shuffle // how to get sol_handle::get_thrust_policy? if (size() > 1) - device_random_shuffle - <<<1, 1, 0, stream_>>>(stack_.data(), size(), seed_generator::get_seed()); + device_random_shuffle<<<1, 1, 0, stream_>>>(stack_.data(), size(), seed); } bool empty() const diff --git a/cpp/src/routing/ges/execute_insertion.cu b/cpp/src/routing/ges/execute_insertion.cu index dbcfc61250..ddec22acee 100644 --- a/cpp/src/routing/ges/execute_insertion.cu +++ b/cpp/src/routing/ges/execute_insertion.cu @@ -7,8 +7,8 @@ #include "../solution/solution.cuh" +#include #include -#include #include "compute_delivery_insertions.cuh" #include "compute_fragment_ejections.cuh" #include "ejection_pool.cuh" @@ -281,7 +281,7 @@ bool guided_ejection_search_t::execute_best_insertion_ejectio solution_ptr->get_num_orders(), solution_ptr->problem_ptr->get_max_break_dimensions(), solution_ptr->get_n_routes()); - int64_t seed = seed_generator::get_seed(); + int64_t seed = solution_ptr->problem_ptr->seed_gen.get_seed(); i_t* p_scores = p_scores_.data(); i_t fragment_size_arg = fragment_size; i_t fragment_step_arg = fragment_step; @@ -406,7 +406,7 @@ i_t guided_ejection_search_t::find_single_insertion( solution_ptr->get_num_orders(), solution_ptr->problem_ptr->get_max_break_dimensions(), solution_ptr->get_n_routes()), - seed_generator::get_seed()); + solution_ptr->problem_ptr->seed_gen.get_seed()); RAFT_CHECK_CUDA(solution_ptr->sol_handle->get_stream()); diff --git a/cpp/src/routing/ges/guided_ejection_search.cu b/cpp/src/routing/ges/guided_ejection_search.cu index 442e7b2b67..1e88375a92 100644 --- a/cpp/src/routing/ges/guided_ejection_search.cu +++ b/cpp/src/routing/ges/guided_ejection_search.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 */ @@ -62,7 +62,7 @@ guided_ejection_search_t::guided_ejection_search_t( (solution.get_num_orders() + solution.problem_ptr->get_max_break_dimensions()), solution.sol_handle->get_stream()), feasible_candidates_size_(solution.sol_handle->get_stream()), - gen_candidate(seed_generator::get_seed()), + gen_candidate(solution.problem_ptr->seed_gen.get_seed()), p_scores_(solution.get_num_orders(), solution.sol_handle->get_stream()), inserted_requests(solution.get_num_orders(), solution.sol_handle->get_stream()), best_squeeze_per_cand(solution.get_num_requests(), solution.sol_handle->get_stream()), @@ -192,7 +192,7 @@ void guided_ejection_search_t::shuffle_pool() raft::common::nvtx::range fun_scope("shuffle_pool"); // include the ejected request in shuffle ++EP.index_; - EP.random_shuffle(); + EP.random_shuffle(solution_ptr->problem_ptr->seed_gen.get_seed()); --EP.index_; if (dump_intermediate) { dump_to_file("Shuffle"); } } @@ -439,7 +439,7 @@ bool guided_ejection_search_t::construct_feasible_solution() } solution_ptr->add_routes(new_routes); // permutate the EP for randomness - EP.random_shuffle(); + EP.random_shuffle(solution_ptr->problem_ptr->seed_gen.get_seed()); bool all_inserted = greedy_insert(); if (!all_inserted) { local_search_ptr_->perturb_solution(*solution_ptr); } diff --git a/cpp/src/routing/ges/lexicographic_search/lexicographic_search.cu b/cpp/src/routing/ges/lexicographic_search/lexicographic_search.cu index fa3a62d482..8be74cd348 100644 --- a/cpp/src/routing/ges/lexicographic_search/lexicographic_search.cu +++ b/cpp/src/routing/ges/lexicographic_search/lexicographic_search.cu @@ -13,7 +13,7 @@ #include "lexicographic_search.cuh" #include -#include +#include #include "raft/core/span.hpp" #include "raft/random/device/sample.cuh" diff --git a/cpp/src/routing/ges/lexicographic_search/node_stack.cuh b/cpp/src/routing/ges/lexicographic_search/node_stack.cuh index 0f0263261e..3fa2c1fbf5 100644 --- a/cpp/src/routing/ges/lexicographic_search/node_stack.cuh +++ b/cpp/src/routing/ges/lexicographic_search/node_stack.cuh @@ -13,7 +13,7 @@ #include "../../solution/solution.cuh" #include -#include +#include #include "raft/core/span.hpp" diff --git a/cpp/src/routing/local_search/compute_insertions.cu b/cpp/src/routing/local_search/compute_insertions.cu index fa2da01aef..1f69065446 100644 --- a/cpp/src/routing/local_search/compute_insertions.cu +++ b/cpp/src/routing/local_search/compute_insertions.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 */ @@ -9,7 +9,7 @@ #include "compute_insertions.cuh" #include "delivery_insertion.cuh" -#include +#include #include "routing/utilities/cuopt_utils.cuh" #include "../routing_helpers.cuh" @@ -831,7 +831,7 @@ void find_insertions(solution_t& sol, cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); find_insertions_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); } else { // for cross the load-balance factor is always 4 move_candidates.number_of_blocks_per_ls_route = @@ -847,7 +847,7 @@ void find_insertions(solution_t& sol, cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); find_insertions_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); } else if (search_type == search_type_t::RANDOM) { // we don't search for relocates in random. n_blocks = sol.get_num_requests(); @@ -859,7 +859,7 @@ void find_insertions(solution_t& sol, cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); find_insertions_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); } } RAFT_CHECK_CUDA(sol.sol_handle->get_stream()); @@ -892,7 +892,7 @@ void find_unserviced_insertions(solution_t& sol, cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); find_insertions_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); RAFT_CHECK_CUDA(sol.sol_handle->get_stream()); sol.sol_handle->sync_stream(); } diff --git a/cpp/src/routing/local_search/fill_gpu_graph.cu b/cpp/src/routing/local_search/fill_gpu_graph.cu index b0fb123824..5cb0e6c81e 100644 --- a/cpp/src/routing/local_search/fill_gpu_graph.cu +++ b/cpp/src/routing/local_search/fill_gpu_graph.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 */ @@ -8,7 +8,7 @@ #include "../solution/solution.cuh" #include "local_search.cuh" -#include +#include #include "../util_kernels/top_k.cuh" #include "cycle_finder/cycle_graph.hpp" #include "routing/utilities/cuopt_utils.cuh" @@ -159,7 +159,7 @@ void local_search_t::fill_gpu_graph(solution_tget_stream(); move_candidates.graph.special_index = solution.get_num_orders() + solution.n_routes; fill_intra_candidates<<>>( - solution.view(), move_candidates.view(), seed_generator::get_seed()); + solution.view(), move_candidates.view(), solution.problem_ptr->seed_gen.get_seed()); // +1 for special node i_t n_blocks = solution.get_num_requests() + 1; fill_graph_kernel diff --git a/cpp/src/routing/local_search/permutation_helper.cuh b/cpp/src/routing/local_search/permutation_helper.cuh index cc1bc37cb1..d590af946e 100644 --- a/cpp/src/routing/local_search/permutation_helper.cuh +++ b/cpp/src/routing/local_search/permutation_helper.cuh @@ -1,13 +1,13 @@ /* 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 */ #pragma once -#include +#include #include "../node/node.cuh" #include "../route/route.cuh" #include "../routing_helpers.cuh" diff --git a/cpp/src/routing/local_search/random_cross.cu b/cpp/src/routing/local_search/random_cross.cu index a54853513f..7d90c96eb6 100644 --- a/cpp/src/routing/local_search/random_cross.cu +++ b/cpp/src/routing/local_search/random_cross.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 */ @@ -204,7 +204,7 @@ void select_random_route_pairs(solution_t& sol, } select_random_route_pairs_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); RAFT_CHECK_CUDA(sol.sol_handle->get_stream()); } @@ -217,7 +217,7 @@ void pick_random_move_per_route_pair(solution_t& sol, auto nblocks = (n_route_pair + nthreads - 1) / nthreads; pick_random_move_per_route_pair_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); RAFT_CHECK_CUDA(sol.sol_handle->get_stream()); } diff --git a/cpp/src/routing/local_search/vrp/vrp_execute.cu b/cpp/src/routing/local_search/vrp/vrp_execute.cu index 5e417a9345..d65ec4fb36 100644 --- a/cpp/src/routing/local_search/vrp/vrp_execute.cu +++ b/cpp/src/routing/local_search/vrp/vrp_execute.cu @@ -394,7 +394,7 @@ i_t extract_non_overlapping_moves(solution_t& sol, cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); extract_non_overlapping_moves_kernel <<<1, TPB, sh_size, sol.sol_handle->get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); return move_candidates.vrp_move_candidates.n_of_selected_moves.value( sol.sol_handle->get_stream()); } diff --git a/cpp/src/routing/problem/problem.cu b/cpp/src/routing/problem/problem.cu index 4335b93734..6868736fc3 100644 --- a/cpp/src/routing/problem/problem.cu +++ b/cpp/src/routing/problem/problem.cu @@ -11,7 +11,7 @@ #include -#include +#include namespace cuopt { namespace routing { namespace detail { @@ -77,8 +77,14 @@ problem_t::problem_t(const data_model_view_t& data_model_vie initialize_incompatible(problem_ref); } - seed_generator::set_seed( - order_info.get_num_requests(), order_info.get_num_orders(), order_info.get_num_orders()); + // A user-supplied seed wins; otherwise derive one from the problem so that a given + // problem still reproduces run to run, which is the historical behaviour. + if (solver_settings_ptr != nullptr && solver_settings_ptr->get_seed() >= 0) { + seed_gen.set_seed(solver_settings_ptr->get_seed()); + } else { + seed_gen.set_seed( + order_info.get_num_requests(), order_info.get_num_orders(), order_info.get_num_orders()); + } } template diff --git a/cpp/src/routing/problem/problem.cuh b/cpp/src/routing/problem/problem.cuh index c2f00bf9f4..46b6c4151b 100644 --- a/cpp/src/routing/problem/problem.cuh +++ b/cpp/src/routing/problem/problem.cuh @@ -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 */ @@ -18,7 +18,7 @@ #include #include #include -#include +#include #include @@ -267,6 +267,10 @@ class problem_t { const data_model_view_t* data_view_ptr; const solver_settings_t* solver_settings_ptr; + // Seed source for this problem. Seeded in the constructor from the solver settings, or + // derived from the problem when the user has not supplied one. + seed_generator_t seed_gen; + i_t get_num_orders() const; i_t get_num_requests() const; diff --git a/cpp/src/routing/solver_settings.cu b/cpp/src/routing/solver_settings.cu index 6267f39698..334a10638c 100644 --- a/cpp/src/routing/solver_settings.cu +++ b/cpp/src/routing/solver_settings.cu @@ -38,6 +38,12 @@ void solver_settings_t::dump_best_results(const std::string& file_path best_result_file_name_ = file_path; } +template +void solver_settings_t::set_seed(i_t seed) +{ + seed_ = seed; +} + template f_t solver_settings_t::get_time_limit() const noexcept { @@ -63,6 +69,12 @@ std::tuple solver_settings_t::get_dump_best_re return std::make_tuple(dump_interval_, dump_best_results_, best_result_file_name_); } +template +i_t solver_settings_t::get_seed() const noexcept +{ + return seed_; +} + template class CUOPT_EXPORT solver_settings_t; } // namespace routing } // namespace cuopt diff --git a/cpp/src/routing/utilities/cuopt_utils.cuh b/cpp/src/routing/utilities/cuopt_utils.cuh index 41900ceebe..f94bc493c2 100644 --- a/cpp/src/routing/utilities/cuopt_utils.cuh +++ b/cpp/src/routing/utilities/cuopt_utils.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -10,7 +10,7 @@ #include "routing/utilities/constants.hpp" #include -#include +#include #include #include diff --git a/cpp/src/routing/utilities/seed_generator.cuh b/cpp/src/routing/utilities/seed_generator.cuh new file mode 100644 index 0000000000..172ec613f9 --- /dev/null +++ b/cpp/src/routing/utilities/seed_generator.cuh @@ -0,0 +1,91 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once +#include +#include + +#include +#include +#include + +namespace cuopt { +namespace routing { + +namespace detail { + +// Folds several values into one seed using the Cantor pairing function. +// +// The arithmetic is done in uint64_t: routing folds `int` problem dimensions, and the +// product overflows a 32-bit int once two equal dimensions reach 181. Signed overflow is +// undefined behaviour, so widen first and let the unsigned type wrap deterministically. +template +inline int64_t fold_seed(seed_t seed) +{ + return static_cast(static_cast(seed)); +} + +template +inline int64_t fold_seed(arg0 seed0, arg1 seed1, args... seeds) +{ + const uint64_t a = static_cast(seed0); + const uint64_t b = static_cast(seed1); + const uint64_t sum = a + b; + return fold_seed(b + sum * (sum + 1) / 2, seeds...); +} + +} // namespace detail + +/** + * @brief Routing's source of deterministic seeds, owned by the problem that uses it. + * + * `problem_t` holds one of these, seeded from the user's `solver_settings_t::set_seed` or, + * when none was given, from the problem's own dimensions. Routing previously drew from a + * process-wide counter shared with the MIP heuristics, so whichever solver constructed its + * problem last overwrote the other's seed. + * + * The counter is `mutable` and atomic so that `get_seed()` can be `const`: `solution_t` + * reaches its problem through a `const` pointer, and drawing a seed does not change the + * problem's logical state. Concurrent callers are handed distinct values, but the order in + * which they receive them is not fixed, so reproducibility still requires a deterministic + * call order. + */ +class seed_generator_t { + mutable std::atomic counter_{0}; + + public: + seed_generator_t() = default; + explicit seed_generator_t(int64_t initial) : counter_(initial) {} + + // std::atomic is neither copyable nor movable, which would delete problem_t's defaulted + // move constructor. Transfer the value instead so the owning problem stays movable. + seed_generator_t(seed_generator_t&& other) noexcept + : counter_(other.counter_.load(std::memory_order_relaxed)) + { + } + + seed_generator_t& operator=(seed_generator_t&& other) noexcept + { + counter_.store(other.counter_.load(std::memory_order_relaxed), std::memory_order_relaxed); + return *this; + } + + template + void set_seed(args... seeds) + { +#ifdef BENCHMARK + counter_.store(static_cast(std::random_device{}()), std::memory_order_relaxed); +#else + counter_.store(detail::fold_seed(seeds...), std::memory_order_relaxed); +#endif + } + + int64_t get_seed() const { return counter_.fetch_add(1, std::memory_order_relaxed); } +}; + +} // namespace routing +} // namespace cuopt From 337aa3c5c4151ef532ab05ab4e601ce74f5aaa9f Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu <42624703+ramakrishnap-nv@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:38:14 -0500 Subject: [PATCH 11/78] fix: build-images CI failures (deadsnakes PPA flakiness + nightly version mismatch) (#1780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes two independent issues causing the `build-images` workflow to fail (e.g. https://github.com/NVIDIA/cuopt/actions/runs/32692816239): - **`ci/docker/Dockerfile`**: `add-apt-repository ppa:deadsnakes/ppa` calls Launchpad's API to fetch the PPA's signing key, which is subject to transient failures (observed `HTTP 500 GPGKeyTemporarilyNotFoundError`). Wrapped it in a 5-attempt retry loop with backoff so a transient Launchpad hiccup doesn't fail the whole image build. - **`.github/workflows/build_test_publish_images.yaml`**: `compute-cuopt-ver` computed `CUOPT_VER` with plain `rapids-generate-version`, but `ci/build_wheel.sh` has published nightly wheels with a `.post` suffix since #1661 (`dbaf523f`). The two were out of sync, so nightly image builds pinned a version (e.g. `26.10.0a43`) that was never actually published — only `.post`-suffixed builds exist on the index — causing `pip install` to fail with "No matching distribution found". Now sets `RAPIDS_VERSION_SUFFIX=".post${RAPIDS_DATETIME_STRING}"` before calling `rapids-generate-version`, mirroring `ci/build_wheel.sh`. `RAPIDS_VERSION_SUFFIX` is a no-op on release builds (confirmed in `rapids-generate-version`'s source), so release tagging/versioning is unaffected. ## Test plan - [ ] Trigger `build-images` workflow (nightly build) and confirm the `python-env` stage installs Python without hitting the deadsnakes PPA error - [ ] Confirm `pip install cuopt-server-cu13==` resolves to a real published nightly wheel - [ ] Confirm release-build version computation (`IMAGE_TAG_PREFIX`) is unchanged for tagged releases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Authors: - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv) Approvers: - Trevor McKay (https://github.com/tmckayus) URL: https://github.com/NVIDIA/cuopt/pull/1780 --- .github/workflows/build_test_publish_images.yaml | 4 +++- ci/docker/Dockerfile | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_test_publish_images.yaml b/.github/workflows/build_test_publish_images.yaml index 8965c5e389..b4f92013b5 100644 --- a/.github/workflows/build_test_publish_images.yaml +++ b/.github/workflows/build_test_publish_images.yaml @@ -84,7 +84,9 @@ jobs: - name: Compute cuopt version id: compute-cuopt-ver run: | - ver=$(rapids-generate-version) + source rapids-datetime-string + # .post makes every nightly version unique (see ci/build_wheel.sh); ignored on release builds. + ver=$(RAPIDS_VERSION_SUFFIX=".post${RAPIDS_DATETIME_STRING}" rapids-generate-version) # Remove starting 0s from version 25.08.0a18 -> 25.8.0a18 CUOPT_VER=$(echo "$ver" | sed -E 's/\.0+([0-9])/\.\1/g') echo "CUOPT_VER=$CUOPT_VER" >> $GITHUB_OUTPUT diff --git a/ci/docker/Dockerfile b/ci/docker/Dockerfile index 33ccd9d3ed..bd63e4337e 100644 --- a/ci/docker/Dockerfile +++ b/ci/docker/Dockerfile @@ -33,7 +33,12 @@ RUN apt-get update \ gnupg2 \ wget \ unzip \ - && add-apt-repository ppa:deadsnakes/ppa \ + && ( for i in 1 2 3 4 5; do \ + add-apt-repository -y ppa:deadsnakes/ppa && exit 0; \ + echo "add-apt-repository failed (attempt ${i}/5), retrying in 5s..."; \ + sleep 5; \ + done; \ + exit 1 ) \ && apt-get install -y --no-install-recommends \ python${PYTHON_SHORT_VER} \ python${PYTHON_SHORT_VER}-dev \ From 889a697eb2b1999d0443afc61e5706e26ecc300d Mon Sep 17 00:00:00 2001 From: "Nicolas L. Guidotti" Date: Tue, 25 Aug 2026 08:11:59 +0200 Subject: [PATCH 12/78] Root heuristics (#1700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactored the root heuristics (more specifically, the CPU FJ launches at the root node) to be more modular, while allowing to use more workers. - Extended recursive RINS to run on the root node - Fixed incorrect objective passed to the `set_solution_from_submip` (the objective was on the submip space, which is not comparable with the solution space of the B&B). MIPLIB 2017: GH200, 10min ``` ================================================================================ main-2026-08-13 (1) vs root-heuristics (2) ================================================================================ ------------------------------------------------------------------------------------------------------------------------------ | | Run 1 | Run 2 | Abs. Diff. | Rel. Diff. (%) | ------------------------------------------------------------------------------------------------------------------------------ | Imported 240 240 +0 --- | | Feasible 226 225 -1 --- | | Optimal 87 87 +0 --- | | Solutions with <0.1% primal gap 145 143 -2 --- | | Nodes explored (mean) 1.306e+07 1.322e+07 +1.63e+05 +1.25 | | Nodes explored (shifted geomean) 1.113e+04 8201 -2930 -26.3 | | Relative MIP gap (mean) 0.2626 0.2695 +0.006939 +2.64 | | Relative MIP gap (shifted geomean) 0.08603 0.0867 +0.0006709 +0.78 | | Solve time (mean) 412.7 415.6 +2.922 +0.708 | | Solve time (shifted geomean) 182.3 179.6 -2.652 -1.46 | | Primal gap (mean) 9.867 10.28 +0.4097 +4.15 | | Primal gap (shifted geomean) 0.3855 0.4062 +0.02071 +5.37 | | Primal integral (mean) 22 22.11 +0.1057 +0.481 | | Primal integral (shifted geomean) 2.274 2.074 -0.1999 -8.79 | ------------------------------------------------------------------------------------------------------------------------------ ---------------------------------------------------------------------- | Name | status 1 | status 2 | ---------------------------------------------------------------------- | app1-2 optimal feasible | | cbs-cta feasible optimal | | cmflsp50-24-8-8 optimal feasible | | csched007 optimal feasible | | map16715-04 feasible optimal | | neos-1171737 feasible optimal | | neos-5104907-jarama feasible timeout | ---------------------------------------------------------------------- ``` Authors: - Nicolas L. Guidotti (https://github.com/nguidotti) Approvers: - Alice Boucher (https://github.com/aliceb-nv) - Akif ÇÖRDÜK (https://github.com/akifcorduk) - Chris Maes (https://github.com/chris-maes) URL: https://github.com/NVIDIA/cuopt/pull/1700 --- .../mip/submip_hyper_params.hpp | 5 + cpp/src/branch_and_bound/branch_and_bound.cpp | 467 ++++++++++-------- cpp/src/branch_and_bound/branch_and_bound.hpp | 25 +- cpp/src/branch_and_bound/constants.hpp | 28 +- .../deterministic_workers.hpp | 48 +- cpp/src/branch_and_bound/worker.hpp | 16 +- cpp/src/branch_and_bound/worker_pool.hpp | 15 +- cpp/src/dual_simplex/solve.cpp | 8 + cpp/src/dual_simplex/solve.hpp | 3 + .../mip_heuristics/feasibility_jump/fj_cpu.cu | 33 +- .../feasibility_jump/fj_cpu_worker.cuh | 6 + .../presolve/third_party_presolve.cpp | 6 +- .../presolve/third_party_presolve.hpp | 3 +- cpp/src/mip_heuristics/root_heuristics.hpp | 134 +++++ cpp/tests/mip/miplib_test.cu | 2 +- 15 files changed, 556 insertions(+), 243 deletions(-) create mode 100644 cpp/src/mip_heuristics/root_heuristics.hpp diff --git a/cpp/include/cuopt/mathematical_optimization/mip/submip_hyper_params.hpp b/cpp/include/cuopt/mathematical_optimization/mip/submip_hyper_params.hpp index 505a3b61ba..4464a156a7 100644 --- a/cpp/include/cuopt/mathematical_optimization/mip/submip_hyper_params.hpp +++ b/cpp/include/cuopt/mathematical_optimization/mip/submip_hyper_params.hpp @@ -41,6 +41,11 @@ struct mip_submip_hyper_params_t { // number of simplex iteration from the parent B&B. f_t iteration_limit_ratio = 0.8; + // If there is not enough variables fixed or we already found an improving solution, + // perform a short DFS to quickly find a feasible solution. This setting controls + // the maximum number of nodes allow for backtracking. + i_t dfs_max_backtrack = 5; + // Run CPU FJ over the sub-MIP bool enable_cpufj = true; }; diff --git a/cpp/src/branch_and_bound/branch_and_bound.cpp b/cpp/src/branch_and_bound/branch_and_bound.cpp index fd7987ae1c..1454884a0e 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.cpp +++ b/cpp/src/branch_and_bound/branch_and_bound.cpp @@ -41,9 +41,17 @@ #include #include #include +#include #include #include +#define SUBMIP_VERBOSE false +#if SUBMIP_VERBOSE +#define DEBUG_SUBMIP(fmt, ...) settings_.log.print_format(fmt, __VA_ARGS__); +#else +#define DEBUG_SUBMIP(fmt, ...) +#endif + namespace cuopt::mathematical_optimization::mip { using simplex::basis_update_mpf_t; @@ -205,6 +213,7 @@ inline char feasible_solution_symbol(heuristics_origin_t origin) inline char feasible_solution_symbol(search_strategy_t strategy, bool show_diving) { if (strategy == search_strategy_t::BEST_FIRST) return 'B'; + if (strategy == search_strategy_t::RINS) return 'S'; if (!show_diving) return 'D'; switch (strategy) { @@ -215,8 +224,10 @@ inline char feasible_solution_symbol(search_strategy_t strategy, bool show_divin case search_strategy_t::GUIDED_DIVING: return 'G'; case search_strategy_t::FARKAS_DIVING: return 'F'; case search_strategy_t::VECTOR_LENGTH_DIVING: return 'V'; - default: return 'U'; + case search_strategy_t::RINS: return 'S'; } + + return 'U'; } template @@ -654,16 +665,19 @@ void branch_and_bound_t::set_solution_from_cpu_fj(f_t obj, // user space. template void branch_and_bound_t::set_solution_from_submip( + const lp_problem_t& lp, const std::vector& solution, const third_party_presolve_t& presolver, - f_t fixrate, - f_t obj) + f_t fixrate) { + bool check_postsolve = false; std::vector leaf_sol; - presolver.uncrush_primal_solution(solution, leaf_sol); + presolver.uncrush_primal_solution(solution, leaf_sol, check_postsolve); + f_t obj = compute_objective(lp, leaf_sol); + std::vector user_sol; mutex_original_lp_.lock(); - uncrush_primal_solution(original_problem_, original_lp_, leaf_sol, user_sol); + uncrush_primal_solution(original_problem_, lp, leaf_sol, user_sol); mutex_original_lp_.unlock(); settings_.log.debug_format("SubMIP found a feasible solution with obj={:.4g}", obj); bool success = set_solution_from_heuristics(user_sol, heuristics_origin_t::SUBMIP); @@ -991,7 +1005,7 @@ branch_variable_t branch_and_bound_t::variable_selection( branch_var = pc_.variable_selection(fractional, solution); } - round_dir = martin_criteria(solution[branch_var], root_relax_soln_.x[branch_var]); + round_dir = martin_criteria(solution[branch_var], worker->root_solution[branch_var]); return {branch_var, round_dir}; @@ -1000,10 +1014,10 @@ branch_variable_t branch_and_bound_t::variable_selection( original_lp_, fractional, solution, var_up_locks_, var_down_locks_, log); case search_strategy_t::LINE_SEARCH_DIVING: - return line_search_diving(fractional, solution, root_relax_soln_.x, log); + return line_search_diving(fractional, solution, worker->root_solution, log); case search_strategy_t::PSEUDOCOST_DIVING: - return pseudocost_diving(pc_, fractional, solution, root_relax_soln_.x, log); + return pseudocost_diving(pc_, fractional, solution, worker->root_solution, log); case search_strategy_t::GUIDED_DIVING: assert(incumbent_.has_incumbent); @@ -1018,9 +1032,9 @@ branch_variable_t branch_and_bound_t::variable_selection( case search_strategy_t::VECTOR_LENGTH_DIVING: return vector_length_diving(worker->leaf_problem, fractional, solution, log); - case search_strategy_t::SUBMIP: // This is used for solving the DFS of the sub-MIP. + case search_strategy_t::RINS: // This is used for solving the DFS of the sub-MIP. branch_var = pc_.variable_selection(fractional, solution); - round_dir = martin_criteria(solution[branch_var], root_relax_soln_.x[branch_var]); + round_dir = martin_criteria(solution[branch_var], worker->root_solution[branch_var]); return {branch_var, round_dir}; } @@ -1173,7 +1187,7 @@ struct deterministic_bfs_policy_t const std::vector& x) override { i_t var = this->worker.pc_snapshot.variable_selection(fractional, x); - auto dir = martin_criteria(x[var], this->bnb.root_relax_soln_.x[var]); + auto dir = martin_criteria(x[var], this->worker.root_solution[var]); return {var, dir}; } @@ -1255,15 +1269,15 @@ struct deterministic_diving_policy_t switch (this->worker.diving_type) { case search_strategy_t::PSEUDOCOST_DIVING: return pseudocost_diving( - this->worker.pc_snapshot, fractional, x, *this->worker.root_solution, log); + this->worker.pc_snapshot, fractional, x, this->worker.root_solution, log); case search_strategy_t::LINE_SEARCH_DIVING: - return line_search_diving(fractional, x, *this->worker.root_solution, log); + return line_search_diving(fractional, x, this->worker.root_solution, log); case search_strategy_t::GUIDED_DIVING: if (this->worker.incumbent_snapshot.empty()) { return pseudocost_diving( - this->worker.pc_snapshot, fractional, x, *this->worker.root_solution, log); + this->worker.pc_snapshot, fractional, x, this->worker.root_solution, log); } else { return guided_diving( this->worker.pc_snapshot, fractional, x, this->worker.incumbent_snapshot, log); @@ -1595,7 +1609,7 @@ dual_status_t branch_and_bound_t::solve_node_lp( bool feasible = worker->set_lp_variable_bounds(node_ptr, settings_); dual_status_t lp_status = dual_status_t::DUAL_UNBOUNDED; - worker->leaf_edge_norms = edge_norms_; + worker->leaf_edge_norms = worker->root_edge_norm; if (worker->recompute_bounds && worker->orbital_fixing && worker->search_strategy == search_strategy_t::BEST_FIRST) { worker->orbital_fixing->reset(symmetry_, node_ptr); @@ -2113,7 +2127,7 @@ void branch_and_bound_t::dive_with(diving_worker_t* worker, // This is called from the RINS method which already handle the return to the // pool part. Besides, they do not share the same pool. - if (worker->search_strategy != search_strategy_t::SUBMIP) { + if (worker->search_strategy != search_strategy_t::RINS) { diving_worker_pool_.return_worker_to_pool(worker); } } @@ -2180,16 +2194,28 @@ bool branch_and_bound_t::launch_rins_worker(const std::vector& so diving_worker_t* worker = rins_worker_pool_.pop_idle_worker(); if (!worker) return false; + std::vector current_incumbent; + mutex_upper_.lock(); + current_incumbent = incumbent_.x; + mutex_upper_.unlock(); + + // Note that this node does not have the vstatus (it was cleared at the start of B&B exploration) + worker->start_node = mip_node_t(root_objective_, root_vstatus_); + worker->leaf_vstatus = root_vstatus_; + worker->leaf_problem.lower = original_lp_.lower; + worker->leaf_problem.upper = original_lp_.upper; + worker->leaf_solution.x = sol; + worker->search_strategy = search_strategy_t::RINS; worker->set_active(); - worker->search_strategy = search_strategy_t::SUBMIP; if (settings_.inside_submip) { // LLVM libomp's GOMP compatibility path skips GCC's firstprivate copy // function for included tasks. - rins(worker, sol); + rins(worker, current_incumbent, var_types_); } else { -#pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) affinity(worker) firstprivate(worker, sol) - rins(worker, sol); +#pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) affinity(worker) \ + firstprivate(worker, current_incumbent) + rins(worker, current_incumbent, var_types_); } return true; @@ -2198,10 +2224,12 @@ bool branch_and_bound_t::launch_rins_worker(const std::vector& so template void branch_and_bound_t::solve_submip(diving_worker_t* worker, const std::vector& current_incumbent, + const std::vector& var_types, i_t num_var_fixed, i_t num_integers, i_t submip_level, - std::string_view log_prefix) + std::string_view log_prefix, + bool is_root_heuristic) { double start_time = tic(); @@ -2216,11 +2244,12 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke if (!feasible) { // This should never happen since we are fixing bounds that are already in the incumbent. rins_stats_.save_infeasible(fixrate); + DEBUG_SUBMIP("{} The problem is infeasible after running bound strengthening!", log_prefix); return; } - f_t user_lower = compute_user_objective(original_lp_, get_lower_bound()); - f_t user_obj = compute_user_objective(original_lp_, upper_bound_.load()); + f_t user_lower = compute_user_objective(worker->leaf_problem, get_lower_bound()); + f_t user_obj = compute_user_objective(worker->leaf_problem, upper_bound_.load()); f_t rel_gap = user_relative_gap(user_obj, user_lower); i_t explored = exploration_stats_.nodes_explored; @@ -2233,10 +2262,10 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke submip_settings.inside_submip = 1; submip_settings.strong_branching_simplex_iteration_limit = 50; submip_settings.submip_settings.level = submip_level; - submip_settings.log.log = false; submip_settings.benchmark_info_ptr = nullptr; + submip_settings.log.log = SUBMIP_VERBOSE; -#ifdef DEBUG_SUBMIP +#ifdef SAVE_SUBMIP_TO_FILE submip_settings.log.log_prefix = std::format("{}{}", settings_.log.log_prefix, worker->worker_id); CUOPT_LOG_INFO("Writting submip %s to MPS file", submip_settings.log.log_prefix); worker->leaf_problem.write_mps(std::format("submip-{}.mps", submip_settings.log.log_prefix), @@ -2254,12 +2283,19 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke submip_settings.relative_mip_gap_tol = std::min(settings_.submip_settings.target_mip_gap, rel_gap); - submip_settings.submip_settings.rins = - settings_.submip_settings.rins != 0 && submip_level <= settings_.submip_settings.max_level; + bool max_recursion = submip_level > settings_.submip_settings.max_level; + submip_settings.submip_settings.rins = settings_.submip_settings.rins != 0 && !max_recursion; + + DEBUG_SUBMIP("{}Sub-MIP: num variables fixed={}/{} ({:.2f}%)", + log_prefix, + num_var_fixed, + num_integers, + fixrate * 100); - submip_settings.log.debug_format( - "Sub-MIP solve settings: time_limit={:.2f}, node_limit={}, iter_limit={} (current_iter={}), " + DEBUG_SUBMIP( + "{}Sub-MIP solve settings: time_limit={:.2f}, node_limit={}, iter_limit={} (current_iter={}), " "tol={:g}", + log_prefix, submip_settings.time_limit, submip_settings.node_limit, submip_settings.branch_and_bound_simplex_iteration_limit, @@ -2270,7 +2306,7 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke // there is only equality rows (the range row vector is empty) and it contains // structural + slacks + cuts constraints/variables. user_problem_t submip_problem(original_problem_.handle_ptr); - simplex::convert_lp_to_user_problem(worker->leaf_problem, var_types_, settings_, submip_problem); + simplex::convert_lp_to_user_problem(worker->leaf_problem, var_types, settings_, submip_problem); third_party_presolve_t presolver; f_t presolve_time_limit = std::min(0.1 * submip_settings.time_limit, 60.0); @@ -2288,13 +2324,12 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke // Also handle optimal if (submip_problem.num_rows == 0 || submip_problem.num_cols == 0) { - submip_settings.log.debug_format( - "Sub-MIP presolved to a trivial {} x {} problem; solving by bound pushing", - submip_problem.num_rows, - submip_problem.num_cols); + DEBUG_SUBMIP("{}Sub-MIP presolved to a trivial {} x {} problem; solving by bound pushing", + log_prefix, + submip_problem.num_rows, + submip_problem.num_cols); std::vector reduced_sol(submip_problem.num_cols); - f_t obj = 0.0; for (i_t j = 0; j < submip_problem.num_cols; ++j) { const f_t c = submip_problem.objective[j]; @@ -2306,26 +2341,30 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke } else { reduced_sol[j] = std::isfinite(l) ? l : (std::isfinite(u) ? u : 0); } - - obj += reduced_sol[j] * c; } - set_solution_from_submip(reduced_sol, presolver, fixrate, obj); + set_solution_from_submip(worker->leaf_problem, reduced_sol, presolver, fixrate); + return; + } + + if (toc(exploration_stats_.start_time) > settings_.time_limit) { + solver_status_ = mip_status_t::TIME_LIMIT; return; } submip_settings.heuristic_preemption_callback = nullptr; submip_settings.dual_simplex_objective_callback = nullptr; submip_settings.set_simplex_solution_callback = nullptr; - submip_settings.solution_callback = [this, &presolver, fixrate](const std::vector& solution, - f_t obj) { - this->set_solution_from_submip(solution, presolver, fixrate, obj); + submip_settings.solution_callback = [this, &presolver, fixrate, worker]( + const std::vector& solution, f_t obj) { + this->set_solution_from_submip(worker->leaf_problem, solution, presolver, fixrate); }; - submip_settings.log.debug_format("Sub-MIP: {} constraints, {} variables, {} nonzeros\n", - submip_problem.num_rows, - submip_problem.num_cols, - submip_problem.A.nnz()); + DEBUG_SUBMIP("{}Sub-MIP: {} constraints, {} variables, {} nonzeros\n", + log_prefix, + submip_problem.num_rows, + submip_problem.num_cols, + submip_problem.A.nnz()); probing_implied_bound_t empty_probing(submip_problem.num_cols); branch_and_bound_t submip_bnb(submip_problem, submip_settings, tic(), empty_probing); @@ -2338,24 +2377,24 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke presolver.crush_primal_solution(submip_problem, current_incumbent, presolved_incumbent); submip_bnb.set_initial_guess(presolved_incumbent); - const f_t user_upper = compute_user_objective(original_lp_, upper_bound_.load()); - const f_t submip_cutoff = - user_upper / submip_bnb.original_lp_.obj_scale - submip_bnb.original_lp_.obj_constant; + const f_t user_upper = compute_user_objective(worker->leaf_problem, upper_bound_.load()); + const f_t submip_cutoff = compute_presolved_objective(submip_bnb.original_lp_, user_upper); submip_bnb.set_initial_upper_bound(submip_cutoff); - submip_bnb.set_initial_pseudocost(pc_, presolver.get_reduced_to_original_map()); + if (!is_root_heuristic) + submip_bnb.set_initial_pseudocost(pc_, presolver.get_reduced_to_original_map()); if (submip_halt_callback_) { // Copy the halt callback to the deeper level. submip_bnb.set_submip_halt_callback(submip_halt_callback_); } else { // This should only be called by the main solver. - submip_bnb.set_submip_halt_callback([this](f_t, f_t submip_lower_bound) { + submip_bnb.set_submip_halt_callback([this, worker](f_t, f_t submip_lower_bound) { f_t user_upper = compute_user_objective(this->original_lp_, this->upper_bound_.load()); bool is_cutoff = original_lp_.obj_scale > 0 ? submip_lower_bound > user_upper : user_upper > submip_lower_bound; bool is_solver_running = this->solver_status_ == mip_status_t::UNSET && this->is_running_; - return is_cutoff || !is_solver_running; + return is_cutoff || !is_solver_running || worker->halt; }); } @@ -2393,8 +2432,9 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke mip_status_t submip_status = submip_bnb.solve(submip_solution); f_t submip_time = toc(start_time); - submip_settings.log.debug_format( - "Sub-MIP: status={}, iterations={} (total={}), presolve_time={:.2f}, total_time={:.2f} \n", + DEBUG_SUBMIP( + "{}Sub-MIP: status={}, iterations={} (total={}), presolve_time={:.2f}, total_time={:.2f} \n", + log_prefix, mip_status_to_string(submip_status), submip_solution.simplex_iterations, exploration_stats_.total_simplex_iters.load(), @@ -2408,7 +2448,7 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke } if (submip_solution.has_incumbent) { - set_solution_from_submip(submip_solution.x, presolver, fixrate, submip_solution.objective); + set_solution_from_submip(worker->leaf_problem, submip_solution.x, presolver, fixrate); } // Accumulate simplex iterations to determine when to stop exploring the sub-MIP @@ -2418,9 +2458,9 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke } template -inline f_t submip_get_max_fixrate(const submip_stats_t& stats, - const mip_submip_hyper_params_t& submip_settings, - pcgenerator_t& rng) +f_t submip_get_max_fixrate(const submip_stats_t& stats, + const mip_submip_hyper_params_t& submip_settings, + pcgenerator_t& rng) { // Adaptive fix rate based on previous successes and failures. f_t low = submip_settings.base_target_fixrate; @@ -2543,11 +2583,13 @@ void extend_variable_fixings(const simplex_solver_settings_t& settings } template -void branch_and_bound_t::rins(diving_worker_t* rins_worker, - const std::vector& node_solution) +void branch_and_bound_t::rins(diving_worker_t* worker, + const std::vector& current_incumbent, + const std::vector& var_types, + bool is_root_heuristic) { raft::common::nvtx::range scope("BB::rins_thread"); - if (rins_worker->orbital_fixing) { rins_worker->orbital_fixing->disable(); } + if (worker->orbital_fixing) { worker->orbital_fixing->disable(); } i_t submip_level = settings_.submip_settings.level + 1; std::string log_prefix = std::format("[RINS {}] ", submip_level); @@ -2555,44 +2597,31 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, ++rins_stats_.total_calls; bool has_submip = false; - const f_t abs_fathom_tol = settings_.absolute_mip_gap_tol / 10; + worker->recompute_bounds = false; + worker->recompute_basis = true; branch_and_bound_stats_t rins_stats; + mip_node_t& node = worker->start_node; + std::vector& lower = worker->leaf_problem.lower; + std::vector& upper = worker->leaf_problem.upper; + std::vector& bounds_changed = worker->bounds_changed; + std::vector& current_sol = worker->leaf_solution.x; - // Note that this node does not have the vstatus (it was clear at the start of B&B exploration) - mip_node_t node = search_tree_.root.detach_copy(); - rins_worker->leaf_vstatus = root_vstatus_; - rins_worker->leaf_problem.lower = original_lp_.lower; - rins_worker->leaf_problem.upper = original_lp_.upper; - rins_worker->leaf_solution.x = node_solution; - rins_worker->recompute_bounds = false; - rins_worker->recompute_basis = true; - - std::vector& lower = rins_worker->leaf_problem.lower; - std::vector& upper = rins_worker->leaf_problem.upper; - std::vector& bounds_changed = rins_worker->bounds_changed; - std::vector& current_sol = rins_worker->leaf_solution.x; std::vector fractional; - i_t num_frac = fractional_variables(settings_, current_sol, var_types_, fractional); - - std::vector current_incumbent; - mutex_upper_.lock(); - current_incumbent = incumbent_.x; - mutex_upper_.unlock(); + i_t num_frac = fractional_variables(settings_, current_sol, var_types, fractional); std::vector integer_list; - get_unfixed_integer_variables(lower, upper, var_types_, settings_.fixed_tol, integer_list); + get_unfixed_integer_variables(lower, upper, var_types, settings_.fixed_tol, integer_list); i_t num_integers = integer_list.size(); - f_t max_fixrate = - submip_get_max_fixrate(rins_stats_, settings_.submip_settings, rins_worker->rng); + f_t max_fixrate = submip_get_max_fixrate(rins_stats_, settings_.submip_settings, worker->rng); f_t min_fixrate = std::min(settings_.submip_settings.min_fixrate, max_fixrate); i_t max_var_fixed = max_fixrate * num_integers; i_t min_var_fixed = min_fixrate * num_integers; i_t num_var_fixed = 0; - while (solver_status_ == mip_status_t::UNSET && is_running_) { + while (solver_status_ == mip_status_t::UNSET && is_running_ && !worker->halt) { // RINS neighbourhood 1: Fix all the integer variables where the starting solution matches the // current incumbent, considering only the fractional values in the current node i_t prev_num_fixed = num_var_fixed; @@ -2608,11 +2637,11 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, // Enough variables has been fixed if (num_var_fixed >= min_var_fixed) { - settings_.log.debug_format("{}Fixed {} variables (max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); + DEBUG_SUBMIP("{}Fixed {} variables (max={}, min={})\n", + log_prefix, + num_var_fixed, + max_var_fixed, + min_var_fixed); has_submip = true; break; } @@ -2637,11 +2666,11 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, // Enough variables were fixed if (num_var_fixed >= min_var_fixed) { - settings_.log.debug_format("{}Fixed {} variables (max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); + DEBUG_SUBMIP("{}Fixed {} variables (max={}, min={})\n", + log_prefix, + num_var_fixed, + max_var_fixed, + min_var_fixed); has_submip = true; break; } @@ -2651,10 +2680,10 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, // an integer solution first in order to reach the fixing threshold. if (prev_num_fixed == num_var_fixed) { extend_variable_fixings(settings_, - rins_worker->leaf_problem.objective, + worker->leaf_problem.objective, fractional, current_sol, - root_relax_soln_.x, + worker->root_solution, max_var_fixed, lower, upper, @@ -2662,21 +2691,21 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, num_var_fixed); if (num_var_fixed >= min_var_fixed) { - settings_.log.debug_format("{}Fixed {} variables (max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); + DEBUG_SUBMIP("{}Fixed {} variables (max={}, min={})\n", + log_prefix, + num_var_fixed, + max_var_fixed, + min_var_fixed); has_submip = true; break; } if (prev_num_fixed == num_var_fixed) { - settings_.log.debug_format("{}Could not fix more variables ({}, max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); + DEBUG_SUBMIP("{}Could not fix more variables ({}, max={}, min={})\n", + log_prefix, + num_var_fixed, + max_var_fixed, + min_var_fixed); has_submip = true; break; } @@ -2693,14 +2722,14 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, // We continue to do this until enough variables were fixed or no variable is left to fix. logger_t log; log.log = false; - dual_status_t lp_status = solve_node_lp(&node, rins_worker, rins_stats, log); + dual_status_t lp_status = solve_node_lp(&node, worker, rins_stats, log); if (lp_status != dual_status_t::OPTIMAL) { break; } fractional.clear(); - num_frac = fractional_variables(settings_, current_sol, var_types_, fractional); + num_frac = fractional_variables(settings_, current_sol, var_types, fractional); - f_t leaf_obj = compute_objective(rins_worker->leaf_problem, current_sol); + f_t leaf_obj = compute_objective(worker->leaf_problem, current_sol); node.lower_bound = leaf_obj; snap_to_lattice(&node, leaf_obj); @@ -2708,11 +2737,11 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, if (num_frac == 0) { // We found a feasible solution when fixing the variables in RINS. - add_feasible_solution(leaf_obj, current_sol, -1, search_strategy_t::SUBMIP); + add_feasible_solution(leaf_obj, current_sol, -1, search_strategy_t::RINS); break; } - rins_worker->recompute_basis = false; + worker->recompute_basis = false; } f_t fixrate = (f_t)num_var_fixed / num_integers; @@ -2723,14 +2752,11 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, // levels up to try to find a feasible solution quickly from the neighbourhood. if (fixrate < settings_.submip_settings.min_fixrate_cap || (settings_.inside_submip && rins_stats_.total_success != 0)) { - // We need to re-populate the vstatus of the node since it was previously cleared. - rins_worker->start_node = std::move(node); - rins_worker->start_node.packed_vstatus = simplex::compress_vstatus(rins_worker->leaf_vstatus); + worker->start_node.packed_vstatus = simplex::compress_vstatus(worker->leaf_vstatus); + worker->start_lower = lower; + worker->start_upper = upper; - rins_worker->start_lower = lower; - rins_worker->start_upper = upper; - - bool is_feasible = rins_worker->presolve_start_bounds(settings_); + bool is_feasible = worker->presolve_start_bounds(settings_); if (is_feasible) { fj_cpu_worker_t submip_fj_cpu_worker; @@ -2743,21 +2769,31 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, f_t time_limit = std::max(settings_.time_limit - toc(exploration_stats_.start_time), 0); f_t work_limit = 1.0; - submip_fj_cpu_worker.create_worker(rins_worker->leaf_problem, - var_types_, - rins_worker->leaf_solution.x, + submip_fj_cpu_worker.create_worker(worker->leaf_problem, + var_types, + worker->leaf_solution.x, settings_, std::format("{} [CPU FJ]", log_prefix), - rins_worker->rng.next_i64()); + worker->rng.next_i64()); submip_fj_cpu_worker.run_sync(time_limit, work_limit); } - dive_with(rins_worker, 5); + // We need the pseudocost to do the DFS, which we do not have during the cut passes. + if (!is_root_heuristic) { + DEBUG_SUBMIP("{} Running a quick DFS for the submip!", log_prefix); + dive_with(worker, settings_.submip_settings.dfs_max_backtrack); + } } } else { - solve_submip( - rins_worker, current_incumbent, num_var_fixed, num_integers, submip_level, log_prefix); + solve_submip(worker, + current_incumbent, + var_types, + num_var_fixed, + num_integers, + submip_level, + log_prefix, + is_root_heuristic); } } @@ -2766,7 +2802,7 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, exploration_stats_.total_simplex_iters += rins_stats.total_simplex_iters; } - settings_.log.debug_format( + DEBUG_SUBMIP( "{}success={}, infeasible={}, calls={}, fixrate={:.4g} ({}), max_fixrate={:.4g} ({}), " "min_fixrate={:.4g} ({})\n", log_prefix, @@ -2780,7 +2816,77 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, min_fixrate, min_var_fixed); - rins_worker_pool_.return_worker_to_pool(rins_worker); + // If the pool is uninitialized (i.e., in the root node), then this just inactivate the worker. + if (!is_root_heuristic) { + rins_worker_pool_.return_worker_to_pool(worker); + } else { + worker->set_inactive(); + } +} + +template +void branch_and_bound_t::launch_root_heuristics( + const lp_problem_t& lp, + const std::vector& sol, + i_t cut_pass, + root_heuristics_t& root_heuristics) +{ + if (settings_.deterministic) return; + if (settings_.num_threads < 2) return; + + // Using shared_ptr here, so the lifetime of the object is tied to the related task. This allows + // the solver to send the stop signal and immediately continue the execution. + auto current_heuristic = + root_heuristics.create_new_cut_pass_heuristic(cut_pass, Arow_, var_types_, sol, edge_norms_); + auto worker_count = root_heuristics.worker_count_; + constexpr bool is_root_heuristic = true; + constexpr bool is_cpufj_enabled = true; + + if (is_cpufj_enabled) { + f_t work_limit = std::numeric_limits::infinity(); + f_t time_limit = settings_.time_limit - toc(exploration_stats_.start_time); + + current_heuristic->fj_cpu_worker_.improvement_callback = + [this](f_t obj, const std::vector& assignment, double work_units) { + set_solution_from_cpu_fj(obj, assignment, work_units); + }; + current_heuristic->fj_cpu_worker_.create_worker( + lp, var_types_, sol, settings_, "[RootCut CPUFJ] "); + ++(*worker_count); + +#pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) \ + affinity(current_heuristic -> fj_cpu_worker_) firstprivate(current_heuristic, worker_count) \ + depend(out : current_heuristic->fj_cpu_worker_.fj_cpu) + { + current_heuristic->fj_cpu_worker_.run_sync(time_limit, work_limit); + --(*worker_count); + } + } + + if (settings_.submip_settings.rins != 0 && incumbent_.has_incumbent) { + diving_worker_t* worker = current_heuristic->create_submip_worker( + cut_pass, lp, settings_, root_objective_, root_vstatus_, sol); + + std::vector current_incumbent; + mutex_upper_.lock(); + current_incumbent = incumbent_.x; + mutex_upper_.unlock(); + + if (settings_.inside_submip) { + // LLVM libomp's GOMP compatibility path skips GCC's firstprivate copy + // function for included tasks. + rins(worker, current_incumbent, current_heuristic->var_types_, is_root_heuristic); + + } else { + ++(*worker_count); +#pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) affinity(worker) \ + firstprivate(current_incumbent, current_heuristic, worker_count) depend(out : *worker) + { + rins(worker, current_incumbent, current_heuristic->var_types_, is_root_heuristic); + --(*worker_count); + } + } + } } template @@ -3329,13 +3435,14 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut nonbasic_list, edge_norms_); } + settings_.log.printf("\n"); solving_root_relaxation_ = false; f_t root_relax_elapsed_time = toc(root_relax_start_time); exploration_stats_.total_lp_solve_time = root_relax_elapsed_time; if (root_status == lp_status_t::INFEASIBLE) { - settings_.log.printf("\nThe root LP relaxation is infeasible\n", + settings_.log.printf("The root LP relaxation is infeasible\n", lp_status_to_string(root_status).c_str()); signal_extend_cliques_.store(true, std::memory_order_release); #pragma omp taskwait depend(in : *clique_signal) @@ -3343,7 +3450,7 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut } if (root_status == lp_status_t::UNBOUNDED) { - settings_.log.printf("\nThe root relaxation is unbounded\n", + settings_.log.printf("The root relaxation is unbounded\n", lp_status_to_string(root_status).c_str()); if (settings_.heuristic_preemption_callback != nullptr) { settings_.heuristic_preemption_callback(); @@ -3354,7 +3461,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut } if (root_status == lp_status_t::TIME_LIMIT) { - settings_.log.printf("\n"); solver_status_ = mip_status_t::TIME_LIMIT; set_final_solution(solution, -inf); signal_extend_cliques_.store(true, std::memory_order_release); @@ -3363,7 +3469,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut } if (root_status == lp_status_t::WORK_LIMIT) { - settings_.log.printf("\n"); solver_status_ = mip_status_t::WORK_LIMIT; set_final_solution(solution, -inf); signal_extend_cliques_.store(true, std::memory_order_release); @@ -3372,7 +3477,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut } if (root_status == lp_status_t::NUMERICAL_ISSUES) { - settings_.log.printf("\n"); solver_status_ = mip_status_t::NUMERICAL; set_final_solution(solution, -inf); signal_extend_cliques_.store(true, std::memory_order_release); @@ -3381,7 +3485,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut } assert(root_status == lp_status_t::OPTIMAL); - settings_.log.printf("\n"); settings_.log.print_format("Root relaxation solution found in {} iterations and {:.2f}s by {}\n", root_relax_soln_.iterations, root_relax_elapsed_time, @@ -3460,12 +3563,7 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut compute_user_objective(original_lp_, root_relax_objective); } - constexpr bool enable_root_cut_cpufj = true; - fj_cpu_worker_t root_fj_cpu_worker; - root_fj_cpu_worker.improvement_callback = - [this](f_t obj, const std::vector& assignment, double work_units) { - set_solution_from_cpu_fj(obj, assignment, work_units); - }; + root_heuristics_t root_heuristics(settings_.num_threads - 1); f_t cut_generation_start_time = tic(); i_t cut_pool_size = 0; @@ -3497,9 +3595,9 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut return mip_status_t::OPTIMAL; } - cut_pass_result_t cut_pass_result; - root_fj_cpu_worker.run_async(settings_.time_limit - toc(exploration_stats_.start_time)); + launch_root_heuristics(original_lp_, root_relax_soln_.x, cut_pass, root_heuristics); + cut_pass_result_t cut_pass_result; cut_pass_result = do_cut_pass(cut_pass, solution, num_fractional, @@ -3518,7 +3616,15 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut root_relax_objective, cut_pool_size, saved_solution); - root_fj_cpu_worker.stop(); + + mutex_upper_.lock(); + if (incumbent_.has_incumbent && incumbent_.x.size() != original_lp_.num_cols) { + std::vector uncrushed_incumbent; + uncrush_primal_solution(original_problem_, original_lp_, incumbent_.x, uncrushed_incumbent); + crush_primal_solution( + original_problem_, original_lp_, uncrushed_incumbent, new_slacks_, incumbent_.x); + } + mutex_upper_.unlock(); if (cut_pass_result.action == cut_pass_action_t::RETURN) { if (settings_.benchmark_info_ptr != nullptr) { @@ -3529,16 +3635,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut return cut_pass_result.status; } if (cut_pass_result.action == cut_pass_action_t::BREAK) { break; } - - if (enable_root_cut_cpufj && !settings_.deterministic && settings_.num_threads >= 2 && - cut_pass + 1 < settings_.max_cut_passes) { - f_t root_cut_cpufj_build_start_time = tic(); - root_fj_cpu_worker.create_worker( - original_lp_, var_types_, root_relax_soln_.x, settings_, "[RootCut CPUFJ] "); - settings_.log.debug("Root cut CPUFJ problem build time after pass %d: %.6f seconds\n", - cut_pass, - toc(root_cut_cpufj_build_start_time)); - } } // Publish the post-cuts root LP value. @@ -3554,19 +3650,7 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut settings_.benchmark_info_ptr->cut_generation_time_sec = cut_generation_time; } if (cut_info.has_cuts()) { - // If the incumbent is set before or during the cut passes, it may not have the correct - // dimensions as cuts add additional constraints/variables to `original_lp_`. - mutex_upper_.lock(); - if (incumbent_.has_incumbent && incumbent_.x.size() != original_lp_.num_cols) { - std::vector uncrushed_incumbent; - uncrush_primal_solution(original_problem_, original_lp_, incumbent_.x, uncrushed_incumbent); - crush_primal_solution( - original_problem_, original_lp_, uncrushed_incumbent, new_slacks_, incumbent_.x); - } - - mutex_upper_.unlock(); - - settings_.log.printf("Cut generation time: %.2f seconds\n", cut_generation_time); + settings_.log.printf("Root cut passes time: %.2f seconds\n", cut_generation_time); settings_.log.printf("Cut pool size : %d\n", cut_pool_size); settings_.log.printf("Size with cuts : %d constraints, %d variables, %d nonzeros\n", original_lp_.num_rows, @@ -3576,30 +3660,8 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut settings_.log.printf("\n"); } - if (enable_root_cut_cpufj && cut_info.has_cuts()) { - f_t root_cut_cpufj_build_start_time = tic(); - // In deterministic mode this CPUFJ is built on the B&B task while the LS deterministic - // CPUFJ is being built on the main thread; both would otherwise race on the global - // seed_generator and pick non-reproducible seeds. Pin a stable seed here so this - // climber's behavior depends only on settings_.random_seed. - int64_t root_cut_cpufj_seed = - settings_.deterministic ? static_cast(settings_.random_seed) : -1; - root_fj_cpu_worker.create_worker(original_lp_, - var_types_, - root_relax_soln_.x, - settings_, - "[RootCut CPUFJ] ", - root_cut_cpufj_seed); - settings_.log.debug("Root cut CPUFJ final problem build time: %.6f seconds\n", - toc(root_cut_cpufj_build_start_time)); - f_t remaining_time = f_t(settings_.time_limit - toc(exploration_stats_.start_time)); - // Reserve at least half of the remaining time for B&B exploration; cap absolute spend - // at 1s so generous budgets don't grant CPUFJ more than the historical ceiling. - f_t fj_time_limit = - settings_.deterministic ? remaining_time : std::min(remaining_time * 0.5, 1.0); - root_fj_cpu_worker.run_sync(fj_time_limit, 0.5); - } - + // Stops the root heuristics and clear the associated data + root_heuristics.stop_and_sync(); set_uninitialized_steepest_edge_norms(original_lp_, basic_list, edge_norms_); pc_.resize(original_lp_.num_cols); @@ -3724,9 +3786,23 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut const i_t num_bfs_workers = std::max(num_workers / 2, 1); const i_t num_submip_workers = std::max(num_workers / 8, 1); const i_t num_diving_workers = std::max(num_workers - num_bfs_workers, 1); - bfs_worker_pool_.init(num_bfs_workers, original_lp_, Arow_, var_types_, symmetry_, settings_); - rins_worker_pool_.init( - num_submip_workers, original_lp_, Arow_, var_types_, symmetry_, settings_, num_bfs_workers); + bfs_worker_pool_.init(num_bfs_workers, + original_lp_, + Arow_, + var_types_, + symmetry_, + settings_, + root_relax_soln_.x, + edge_norms_); + rins_worker_pool_.init(num_submip_workers, + original_lp_, + Arow_, + var_types_, + symmetry_, + settings_, + root_relax_soln_.x, + edge_norms_, + num_bfs_workers); if (num_diving_workers > 0) { diving_worker_pool_.init(num_diving_workers, @@ -3735,6 +3811,8 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut var_types_, symmetry_, settings_, + root_relax_soln_.x, + edge_norms_, num_bfs_workers + num_submip_workers); } @@ -3917,7 +3995,7 @@ void branch_and_bound_t::run_deterministic_coordinator(const csr_matri deterministic_global_termination_status_ = mip_status_t::UNSET; deterministic_workers_ = std::make_unique>( - num_bfs_workers, original_lp_, Arow, var_types_, settings_); + num_bfs_workers, original_lp_, Arow, var_types_, settings_, root_relax_soln_.x, edge_norms_); if (num_diving_workers > 0) { // Extract diving types from search_strategies (skip BEST_FIRST at index 0) @@ -3936,7 +4014,8 @@ void branch_and_bound_t::run_deterministic_coordinator(const csr_matri Arow, var_types_, settings_, - &root_relax_soln_.x); + root_relax_soln_.x, + edge_norms_); } } diff --git a/cpp/src/branch_and_bound/branch_and_bound.hpp b/cpp/src/branch_and_bound/branch_and_bound.hpp index 96b8a6d8fe..fb3f9fce5d 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.hpp +++ b/cpp/src/branch_and_bound/branch_and_bound.hpp @@ -34,12 +34,14 @@ #include #include +#include #include #include #include #include +#include #include #include @@ -368,24 +370,31 @@ class branch_and_bound_t { // Launch a new RINS worker bool launch_rins_worker(const std::vector& sol); - void set_solution_from_submip(const std::vector& solution, + void set_solution_from_submip(const simplex::lp_problem_t& lp, + const std::vector& solution, const third_party_presolve_t& presolver, - f_t fixrate, - f_t obj); + f_t fixrate); // Solve the RINS sub-MIP void solve_submip(diving_worker_t* worker, const std::vector& current_incumbent, + const std::vector& var_types, i_t num_var_fixed, i_t num_integers, i_t submip_level, - std::string_view log_prefix); + std::string_view log_prefix, + bool is_root_heuristic = false); // Creates and solves the RINS sub-MIP - void rins(diving_worker_t* rins_worker, const std::vector& node_solution); - - // Get the simplex settings for solving the LP of a single node - simplex::simplex_solver_settings_t get_node_lp_settings(); + void rins(diving_worker_t* worker, + const std::vector& current_incumbent, + const std::vector& var_types, + bool is_root_heuristic = false); + + void launch_root_heuristics(const simplex::lp_problem_t& lp, + const std::vector& sol, + i_t cut_pass, + root_heuristics_t& root_heuristics); // Solve the LP relaxation of a leaf node simplex::dual_status_t solve_node_lp(mip_node_t* node_ptr, diff --git a/cpp/src/branch_and_bound/constants.hpp b/cpp/src/branch_and_bound/constants.hpp index 5629360a86..2bc94c8f1b 100644 --- a/cpp/src/branch_and_bound/constants.hpp +++ b/cpp/src/branch_and_bound/constants.hpp @@ -27,15 +27,31 @@ enum class heuristics_origin_t { // doi: 10.1007/s10107-004-0518-7. enum class search_strategy_t : int { BEST_FIRST = 0, // Best-First + Plunging. - PSEUDOCOST_DIVING = 1, // Pseudocost diving (9.2.5) - LINE_SEARCH_DIVING = 2, // Line search diving (9.2.4) - GUIDED_DIVING = 3, // Guided diving (9.2.3). - COEFFICIENT_DIVING = 4, // Coefficient diving (9.2.1) + PSEUDOCOST_DIVING = 1, // Pseudocost diving [1, Section 9.2.5] + LINE_SEARCH_DIVING = 2, // Line search diving [1, Section 9.2.4] + GUIDED_DIVING = 3, // Guided diving. [1, Section 9.2.3] + COEFFICIENT_DIVING = 4, // Coefficient diving [1, Section 9.2.1] FARKAS_DIVING = 5, // Farkas Diving (see [2]) - VECTOR_LENGTH_DIVING = 6, // Vector Length Diving (9.2.6) - SUBMIP = 7 // RINS (see [3]) + VECTOR_LENGTH_DIVING = 6, // Vector Length Diving [1, Section 9.2.6] + RINS = 7, // RINS (see [3]) }; enum class branch_direction_t { NONE = -1, DOWN = 0, UP = 1 }; +inline const char* search_strategy_to_string(search_strategy_t search_strategy) +{ + switch (search_strategy) { + case search_strategy_t::BEST_FIRST: return "BEST_FIRST"; + case search_strategy_t::PSEUDOCOST_DIVING: return "PSEUDOCOST_DIVING"; + case search_strategy_t::LINE_SEARCH_DIVING: return "LINE_SEARCH_DIVING"; + case search_strategy_t::GUIDED_DIVING: return "GUIDED_DIVING"; + case search_strategy_t::COEFFICIENT_DIVING: return "COEFFICIENT_DIVING"; + case search_strategy_t::FARKAS_DIVING: return "FARKAS_DIVING"; + case search_strategy_t::VECTOR_LENGTH_DIVING: return "VECTOR_LENGTH_DIVING"; + case search_strategy_t::RINS: return "RINS"; + } + + return "UNKNOWN"; +} + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/branch_and_bound/deterministic_workers.hpp b/cpp/src/branch_and_bound/deterministic_workers.hpp index 4de3086e61..7c426452a8 100644 --- a/cpp/src/branch_and_bound/deterministic_workers.hpp +++ b/cpp/src/branch_and_bound/deterministic_workers.hpp @@ -89,8 +89,10 @@ class deterministic_worker_base_t : public branch_and_bound_worker_t { const csr_matrix_t& Arow, const std::vector& var_types, const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm, const std::string& context_name) - : base_t(id, original_lp, Arow, var_types, settings), + : base_t(id, original_lp, Arow, var_types, settings, root_solution, root_edge_norm), work_context(context_name), pc_snapshot(1, settings) { @@ -140,8 +142,17 @@ class deterministic_bfs_worker_t const simplex::lp_problem_t& original_lp, const csr_matrix_t& Arow, const std::vector& var_types, - const simplex::simplex_solver_settings_t& settings) - : base_t(id, original_lp, Arow, var_types, settings, "BB_Worker_" + std::to_string(id)) + const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm) + : base_t(id, + original_lp, + Arow, + var_types, + settings, + root_solution, + root_edge_norm, + "BB_Worker_" + std::to_string(id)) { } @@ -282,9 +293,6 @@ class deterministic_diving_worker_t std::vector dive_lower; std::vector dive_upper; - // Root LP relaxation solution (constant, set once at construction) - const std::vector* root_solution{nullptr}; - // Diving state bool recompute_bounds_and_basis{true}; @@ -300,10 +308,17 @@ class deterministic_diving_worker_t const csr_matrix_t& Arow, const std::vector& var_types, const simplex::simplex_solver_settings_t& settings, - const std::vector* root_sol) - : base_t(id, original_lp, Arow, var_types, settings, "Diving_Worker_" + std::to_string(id)), - diving_type(type), - root_solution(root_sol) + const std::vector& root_solution, + const std::vector& root_edge_norm) + : base_t(id, + original_lp, + Arow, + var_types, + settings, + root_solution, + root_edge_norm, + "Diving_Worker_" + std::to_string(id)), + diving_type(type) { dive_lower = original_lp.lower; dive_upper = original_lp.upper; @@ -407,11 +422,14 @@ class deterministic_bfs_worker_pool_t const simplex::lp_problem_t& original_lp, const csr_matrix_t& Arow, const std::vector& var_types, - const simplex::simplex_solver_settings_t& settings) + const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm) { this->workers_.reserve(num_workers); for (int i = 0; i < num_workers; ++i) { - this->workers_.emplace_back(i, original_lp, Arow, var_types, settings); + this->workers_.emplace_back( + i, original_lp, Arow, var_types, settings, root_solution, root_edge_norm); } } @@ -443,12 +461,14 @@ class deterministic_diving_worker_pool_t const csr_matrix_t& Arow, const std::vector& var_types, const simplex::simplex_solver_settings_t& settings, - const std::vector* root_solution) + const std::vector& root_solution, + const std::vector& root_edge_norm) { this->workers_.reserve(num_workers); for (int i = 0; i < num_workers; ++i) { search_strategy_t type = diving_types[i % diving_types.size()]; - this->workers_.emplace_back(i, type, original_lp, Arow, var_types, settings, root_solution); + this->workers_.emplace_back( + i, type, original_lp, Arow, var_types, settings, root_solution, root_edge_norm); } } diff --git a/cpp/src/branch_and_bound/worker.hpp b/cpp/src/branch_and_bound/worker.hpp index fd5255f86f..50e9a4a206 100644 --- a/cpp/src/branch_and_bound/worker.hpp +++ b/cpp/src/branch_and_bound/worker.hpp @@ -78,6 +78,9 @@ class branch_and_bound_worker_t { bool recompute_basis = true; bool recompute_bounds = true; + const std::vector& root_solution; + const std::vector& root_edge_norm; + void ensure_orbital_fixing() { if (orbital_fixing == nullptr && symmetry_ptr != nullptr) { @@ -94,6 +97,8 @@ class branch_and_bound_worker_t { const csr_matrix_t& Arow, const std::vector& var_type, const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm, uint64_t rng_offset = 0) : worker_id(worker_id), search_strategy(search_strategy_t::BEST_FIRST), @@ -108,7 +113,9 @@ class branch_and_bound_worker_t { node_presolver(leaf_problem, Arow, {}, var_type), bounds_changed(original_lp.num_cols, false), rng(settings.random_seed + pcgenerator_t::default_seed + rng_offset + worker_id, - pcgenerator_t::default_stream ^ (worker_id + rng_offset)) + pcgenerator_t::default_stream ^ (worker_id + rng_offset)), + root_solution(root_solution), + root_edge_norm(root_edge_norm) { } @@ -146,8 +153,11 @@ class bfs_worker_t : public branch_and_bound_worker_t { const csr_matrix_t& Arow, const std::vector& var_type, const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm, uint64_t rng_offset = 0) - : Base(worker_id, original_lp, Arow, var_type, settings, rng_offset) + : Base( + worker_id, original_lp, Arow, var_type, settings, root_solution, root_edge_norm, rng_offset) { this->start_lower = original_lp.lower; this->start_upper = original_lp.upper; @@ -243,6 +253,8 @@ class diving_worker_t : public branch_and_bound_worker_t { // The best-first worker that is associated with this diving worker. Used for controlling the // number of active diving workers. bfs_worker_t* bfs_worker{nullptr}; + + std::atomic halt = false; }; struct submip_stats_t { diff --git a/cpp/src/branch_and_bound/worker_pool.hpp b/cpp/src/branch_and_bound/worker_pool.hpp index e9d55bafe3..6977c7882b 100644 --- a/cpp/src/branch_and_bound/worker_pool.hpp +++ b/cpp/src/branch_and_bound/worker_pool.hpp @@ -24,6 +24,8 @@ class worker_pool_t { const std::vector& var_type, mip_symmetry_t* symmetry, const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm, const uint64_t rng_offset = 0) { assert(!is_initialized_); @@ -33,8 +35,8 @@ class worker_pool_t { num_idle_workers_ = num_workers; idle_workers_.clear_resize(num_workers); for (i_t i = 0; i < num_workers; ++i) { - workers_[i] = - std::make_unique(i, original_lp, Arow, var_type, settings, rng_offset); + workers_[i] = std::make_unique( + i, original_lp, Arow, var_type, settings, root_solution, root_edge_norm, rng_offset); idle_workers_.push_back(i); // Propagate the (possibly null) symmetry pointer; workers lazily build // their orbital_fixing/lexical_reduction state via ensure_orbital_fixing(). @@ -61,14 +63,17 @@ class worker_pool_t { void return_worker_to_pool(WorkerType* worker) { - std::lock_guard lock(mutex_); assert(worker != nullptr); + worker->set_inactive(); + assert(!worker->is_active.load()); + + if (!is_initialized_) return; + + std::lock_guard lock(mutex_); assert(workers_[worker->worker_id].get() == worker); assert(static_cast(num_idle_workers_.load()) == idle_workers_.size()); assert(idle_workers_.size() <= workers_.size()); - worker->set_inactive(); - assert(!worker->is_active.load()); idle_workers_.push_back(worker->worker_id); num_idle_workers_++; } diff --git a/cpp/src/dual_simplex/solve.cpp b/cpp/src/dual_simplex/solve.cpp index 7907abd3b9..388bb43b35 100644 --- a/cpp/src/dual_simplex/solve.cpp +++ b/cpp/src/dual_simplex/solve.cpp @@ -104,6 +104,12 @@ f_t compute_user_objective(const lp_problem_t& lp, f_t obj) return user_obj; } +template +f_t compute_presolved_objective(const lp_problem_t& lp, f_t user_obj) +{ + return user_obj / lp.obj_scale - lp.obj_constant; +} + template lp_status_t solve_linear_program_advanced(const lp_problem_t& original_lp, const f_t start_time, @@ -813,6 +819,8 @@ template double compute_user_objective(const lp_problem_t& lp, double obj); +template double compute_presolved_objective(const lp_problem_t& lp, double user_obj); + template lp_status_t solve_linear_program_advanced( const lp_problem_t& original_lp, const double start_time, diff --git a/cpp/src/dual_simplex/solve.hpp b/cpp/src/dual_simplex/solve.hpp index 90c2dbd690..308c462de5 100644 --- a/cpp/src/dual_simplex/solve.hpp +++ b/cpp/src/dual_simplex/solve.hpp @@ -63,6 +63,9 @@ f_t compute_user_objective(const lp_problem_t& lp, const std::vector f_t compute_user_objective(const lp_problem_t& lp, f_t obj); +template +f_t compute_presolved_objective(const lp_problem_t& lp, f_t user_obj); + template lp_status_t solve_linear_program_advanced(const lp_problem_t& original_lp, const f_t start_time, diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 5d9b0267b1..b789159953 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -2109,37 +2109,52 @@ void fj_cpu_worker_t::create_worker( fj_cpu.reset(new_climber.release()); fj_cpu->log_prefix = std::move(log_prefix); fj_cpu->improvement_callback = improvement_callback; + fj_cpu->halted = false; + preemption_flag = false; + is_initialized = true; } template void fj_cpu_worker_t::run_async(f_t time_limit, double work_unit_limit) { - if (!fj_cpu) return; + if (!is_initialized) return; -#pragma omp task shared(fj_cpu) firstprivate(time_limit, work_unit_limit) \ - priority(CUOPT_DEFAULT_TASK_PRIORITY) default(none) depend(out : *fj_cpu) - cpufj_solve(fj_cpu.get(), time_limit, work_unit_limit); + auto& fj_ptr = fj_cpu; +#pragma omp task shared(fj_cpu, is_initialized, fj_ptr) firstprivate(time_limit, work_unit_limit) \ + priority(CUOPT_DEFAULT_TASK_PRIORITY) default(none) depend(out : fj_ptr) + { + if (is_initialized) { cpufj_solve(fj_cpu.get(), time_limit, work_unit_limit); } + } } template void fj_cpu_worker_t::run_sync(f_t time_limit, double work_unit_limit) { - if (!fj_cpu) return; + if (!is_initialized) return; cpufj_solve(fj_cpu.get(), time_limit, work_unit_limit); + is_initialized = false; fj_cpu.reset(); } template void fj_cpu_worker_t::stop() { - if (!fj_cpu) return; + if (!is_initialized) return; + + preemption_flag = true; - fj_cpu->preemption_flag = true; - fj_cpu->halted = true; -#pragma omp taskwait depend(in : *fj_cpu) + auto& fj_ptr = fj_cpu; +#pragma omp taskwait depend(in : fj_ptr) + is_initialized = false; fj_cpu.reset(); } +template +void fj_cpu_worker_t::send_stop_signal() +{ + preemption_flag = true; +} + #if MIP_INSTANTIATE_FLOAT template class fj_t; template struct fj_cpu_worker_t; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh index ff6022c4c2..bb2c69f81c 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh @@ -10,6 +10,8 @@ #include #include +#include + #include #include #include @@ -28,6 +30,8 @@ struct fj_cpu_worker_t { struct fj_cpu_deleter_t { void operator()(fj_cpu_climber_t* ptr) const; }; + + std::atomic is_initialized{false}; std::atomic preemption_flag{false}; std::unique_ptr, fj_cpu_deleter_t> fj_cpu; std::function&, double)> improvement_callback; @@ -55,6 +59,8 @@ struct fj_cpu_worker_t { double work_unit_limit = std::numeric_limits::infinity()); void stop(); + + void send_stop_signal(); }; } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp b/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp index 3d324d2007..3d5b165abb 100644 --- a/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp +++ b/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp @@ -1293,9 +1293,9 @@ void third_party_presolve_t::undo(std::vector& primal_solution, template void third_party_presolve_t::uncrush_primal_solution( - const std::vector& reduced_primal, std::vector& full_primal) const + const std::vector& reduced_primal, std::vector& full_primal, bool check_postsolve) const { - if (presolver_ == cuopt::mathematical_optimization::presolver_t::PSLP) { + if (presolver_ == PSLP) { cuopt_expects(false, error_type_t::RuntimeError, "This code path should be never called, as this is meant for callbacks and they " @@ -1311,7 +1311,7 @@ void third_party_presolve_t::uncrush_primal_solution( bool is_optimal = false; auto status = post_solver.undo(reduced_sol, full_sol, *papilo_post_solve_storage_, is_optimal); - check_postsolve_status(status); + if (check_postsolve) check_postsolve_status(status); full_primal = std::move(full_sol.primal); } diff --git a/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp b/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp index 60f4f7fc6d..79eb28d01e 100644 --- a/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp +++ b/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp @@ -141,7 +141,8 @@ class third_party_presolve_t { bool dual_postsolve); void uncrush_primal_solution(const std::vector& reduced_primal, - std::vector& full_primal) const; + std::vector& full_primal, + bool check_postsolve = true) const; void crush_primal_solution(const optimization_problem_t& reduced_problem, const std::vector& original_primal, diff --git a/cpp/src/mip_heuristics/root_heuristics.hpp b/cpp/src/mip_heuristics/root_heuristics.hpp new file mode 100644 index 0000000000..f54aff7c1b --- /dev/null +++ b/cpp/src/mip_heuristics/root_heuristics.hpp @@ -0,0 +1,134 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include +#include +#include "feasibility_jump/fj_cpu_worker.cuh" + +namespace cuopt::mathematical_optimization::mip { + +template +struct cut_pass_heuristics_t { + std::vector var_types_; + csr_matrix_t Arow_; + std::vector root_solution_; + std::vector root_edge_norm_; + + std::unique_ptr> submip_worker_; + fj_cpu_worker_t fj_cpu_worker_; + + cut_pass_heuristics_t(const csr_matrix_t& Arow, + const std::vector& var_types, + const std::vector& root_solution, + const std::vector& root_edge_norm) + : var_types_(var_types), + Arow_(Arow), + root_solution_(root_solution), + root_edge_norm_(root_edge_norm), + submip_worker_(nullptr) {}; + + ~cut_pass_heuristics_t() { stop_and_sync(); } + + void send_stop_signal() + { + fj_cpu_worker_.send_stop_signal(); + if (submip_worker_) { submip_worker_->halt = true; } + } + + void stop_and_sync() + { + fj_cpu_worker_.stop(); + if (submip_worker_) { + diving_worker_t* worker = submip_worker_.get(); + worker->halt = true; +#pragma omp taskwait depend(in : *worker) + submip_worker_.reset(); + } + } + + diving_worker_t* create_submip_worker( + i_t id, + const simplex::lp_problem_t& lp, + const simplex::simplex_solver_settings_t& settings, + f_t root_obj, + const std::vector& root_vstatus, + const std::vector& sol) + { + submip_worker_ = std::make_unique>( + id, lp, Arow_, var_types_, settings, root_solution_, root_edge_norm_); + submip_worker_->start_node = mip_node_t(root_obj, root_vstatus); + submip_worker_->leaf_vstatus = root_vstatus; + submip_worker_->leaf_solution.x = sol; + submip_worker_->recompute_bounds = false; + submip_worker_->recompute_basis = true; + submip_worker_->search_strategy = search_strategy_t::RINS; + submip_worker_->set_active(); + + return submip_worker_.get(); + } +}; + +/// \brief Object Representing the heuristics run on the root node. +template +struct root_heuristics_t { + // List of the heuristics that run alongside a single cut pass. + // It holds the workers and all the necessary information. + // + // We use the `shared_ptr` here so the object is only destroyed when the task terminates + // (we declare the `shared_ptr` as firstprivate in the task, so they live until the end the + // task). In this way, we can send the stop signal, destroy the entry in the list and the + // object itself will be destroyed when all related tasks ends. + std::list>> cut_passes_heuristics_; + + // Count the number of active workers. Same reason as above. + std::shared_ptr> worker_count_; + i_t max_workers_; + + root_heuristics_t(i_t max_workers) + : worker_count_(std::make_shared>(0)), max_workers_(max_workers) + { + } + + ~root_heuristics_t() { stop_and_sync(); } + + void stop_and_sync() + { + for (auto& heuristic : cut_passes_heuristics_) { + heuristic->send_stop_signal(); + } + + for (auto& heuristic : cut_passes_heuristics_) { + heuristic->stop_and_sync(); + } + } + + std::shared_ptr> create_new_cut_pass_heuristic( + i_t cut_pass, + const csr_matrix_t& Arow, + const std::vector& var_types, + const std::vector& root_solution, + const std::vector& root_edge_norm) + { + // If we already exhausted all threads for the root heuristics, stop workers for the + // oldest set of heuristics launched. Leave 2 threads for the cut passes and the clique + // table generation. Add the number of workers that will be launched (1 submip worker + + // 1 CPU FJ worker). + i_t clique_table_generation = cut_pass == 0 ? 1 : 0; + if (*worker_count_ + 3 + clique_table_generation > max_workers_ && + !cut_passes_heuristics_.empty()) { + cut_passes_heuristics_.begin()->get()->send_stop_signal(); + cut_passes_heuristics_.erase(cut_passes_heuristics_.begin()); + } + + return cut_passes_heuristics_.emplace_back(std::make_shared>( + Arow, var_types, root_solution, root_edge_norm)); + } +}; + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/tests/mip/miplib_test.cu b/cpp/tests/mip/miplib_test.cu index 924763c437..3bdd6f51ff 100644 --- a/cpp/tests/mip/miplib_test.cu +++ b/cpp/tests/mip/miplib_test.cu @@ -97,7 +97,7 @@ TEST(mip_solve, node_limit_test) { mip_solver_settings_t settings; settings.node_limit = 1000; - settings.time_limit = 60; + settings.time_limit = 120; settings.num_cpu_threads = 8; double expect_obj = 3.8151140644999992e+02; From 37f0effaea9507bbb96848763c39f8727a300e25 Mon Sep 17 00:00:00 2001 From: "Nicolas L. Guidotti" Date: Tue, 25 Aug 2026 10:58:25 +0200 Subject: [PATCH 13/78] RENS (#1719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR implements RENS (*Relaxation Enforced Neighborhood Search*, see [1, 2]) heuristic. RENS construct and then solve the following sub-MIP: ```math \begin{align} & z^* = \min \, \vec{c}^T \vec{x} \\ & \text{subject to} & \mathbf{A} \vec{x} = \vec{b} \\ & & l_i \leq x_i \leq u_i, \forall i \in \mathcal{N} \setminus \mathcal{I} \\ & & \lfloor \check{x}_j \rfloor \leq x_i \leq \lceil \check{x}_j \rceil, \forall i \in \mathcal{I} \\ & & \vec{x} \in \mathbb{R}^n \\ & & x_j \in \mathbb{Z}, \forall j \in \mathcal{I} \end{align} ``` where $\mathcal{N}$ is the set of all variables and $\mathcal{I}$ is the set of integer values. In essence, all integer variables with integral values are fixed, while the ones with fractional values are restricted to be rounded up or down. This re-use the same infrastructure as RINS, i.e., it passes through presolve and can recurse. It requires some refactoring of the sub-MIP code to support these additional features. Closes #1584. ## Results MIPLIB2017, GH200, 10min ``` ================================================================================ root-heuristics-1 (1) vs rens-1 (2) ================================================================================ ------------------------------------------------------------------------------------------------------------------------------ | | Run 1 | Run 2 | Abs. Diff. | Rel. Diff. (%) | ------------------------------------------------------------------------------------------------------------------------------ | Imported 240 240 +0 --- | | Feasible 227 227 +0 --- | | Optimal 87 92 +5 --- | | Solutions with <0.1% primal gap 145 144 -1 --- | | Nodes explored (mean) 1.366e+07 1.309e+07 -5.731e+05 -4.19 | | Nodes explored (shifted geomean) 8947 5351 -3596 -40.2 | | Relative MIP gap (mean) 0.2709 0.248 -0.02291 -8.45 | | Relative MIP gap (shifted geomean) 0.08762 0.087 -0.0006245 -0.713 | | Solve time (mean) 419 405.6 -13.35 -3.19 | | Solve time (shifted geomean) 189.4 176 -13.38 -7.06 | | Primal gap (mean) 9.237 9.634 +0.3963 +4.29 | | Primal gap (shifted geomean) 0.3752 0.3633 -0.01198 -3.19 | | Primal integral (mean) 20.14 20.08 -0.06726 -0.334 | | Primal integral (shifted geomean) 2.071 1.978 -0.09335 -4.51 | ------------------------------------------------------------------------------------------------------------------------------ ---------------------------------------------------------------------- | Name | status 1 | status 2 | ---------------------------------------------------------------------- | app1-2 feasible optimal | | cbs-cta feasible optimal | | glass-sc feasible optimal | | mzzv11 feasible optimal | | n2seq36q feasible optimal | | neos-5093327-huahum optimal feasible | | triptim1 feasible optimal | ---------------------------------------------------------------------- ================================================================================ root-heuristics-2 (1) vs rens-2 (2) ================================================================================ ------------------------------------------------------------------------------------------------------------------------------ | | Run 1 | Run 2 | Abs. Diff. | Rel. Diff. (%) | ------------------------------------------------------------------------------------------------------------------------------ | Imported 240 240 +0 --- | | Feasible 225 228 +3 --- | | Optimal 87 91 +4 --- | | Solutions with <0.1% primal gap 143 145 +2 --- | | Nodes explored (mean) 1.322e+07 1.306e+07 -1.595e+05 -1.21 | | Nodes explored (shifted geomean) 8201 6479 -1722 -21 | | Relative MIP gap (mean) 0.2695 0.2752 +0.005638 +2.09 | | Relative MIP gap (shifted geomean) 0.0867 0.08533 -0.001362 -1.57 | | Solve time (mean) 415.6 407.4 -8.174 -1.97 | | Solve time (shifted geomean) 179.6 175.6 -4.007 -2.23 | | Primal gap (mean) 10.28 9.136 -1.141 -11.1 | | Primal gap (shifted geomean) 0.4062 0.3614 -0.04481 -11 | | Primal integral (mean) 22.11 19.93 -2.177 -9.85 | | Primal integral (shifted geomean) 2.074 1.961 -0.1133 -5.46 | ------------------------------------------------------------------------------------------------------------------------------ ---------------------------------------------------------------------- | Name | status 1 | status 2 | ---------------------------------------------------------------------- | app1-2 feasible optimal | | cmflsp50-24-8-8 feasible optimal | | glass-sc feasible optimal | | neos-3216931-puriri timeout feasible | | neos-5104907-jarama timeout feasible | | peg-solitaire-a3 timeout optimal | ---------------------------------------------------------------------- ``` ## References [1] T. Berthold, “RENS: The optimal rounding,” Math. Prog. Comp., vol. 6, no. 1, pp. 33–54, Mar. 2014, doi: 10.1007/s12532-013-0060-9. [2] T. Achterberg, “Constraint Integer Programming,” PhD, Technischen Universität Berlin, Berlin, 2007. doi: 10.14279/depositonce-1634. Authors: - Nicolas L. Guidotti (https://github.com/nguidotti) Approvers: - Alice Boucher (https://github.com/aliceb-nv) - Akif ÇÖRDÜK (https://github.com/akifcorduk) URL: https://github.com/NVIDIA/cuopt/pull/1719 --- .../mathematical_optimization/constants.h | 19 +- .../mip/submip_hyper_params.hpp | 12 +- cpp/src/branch_and_bound/branch_and_bound.cpp | 605 +++++++++++------- cpp/src/branch_and_bound/branch_and_bound.hpp | 24 +- cpp/src/branch_and_bound/constants.hpp | 4 + cpp/src/branch_and_bound/worker.hpp | 2 + cpp/src/math_optimization/solver_settings.cu | 5 +- .../diversity/recombiners/sub_mip.cuh | 1 + cpp/src/mip_heuristics/root_heuristics.hpp | 5 +- 9 files changed, 416 insertions(+), 261 deletions(-) diff --git a/cpp/include/cuopt/mathematical_optimization/constants.h b/cpp/include/cuopt/mathematical_optimization/constants.h index 0ac8c01875..787f57bec5 100644 --- a/cpp/include/cuopt/mathematical_optimization/constants.h +++ b/cpp/include/cuopt/mathematical_optimization/constants.h @@ -78,6 +78,7 @@ #define CUOPT_MIP_STRONG_CHVATAL_GOMORY_CUTS "mip_strong_chvatal_gomory_cuts" #define CUOPT_MIP_REDUCED_COST_STRENGTHENING "mip_reduced_cost_strengthening" #define CUOPT_MIP_RINS "mip_rins" +#define CUOPT_MIP_RENS "mip_rens" #define CUOPT_MIP_OBJECTIVE_STEP "mip_objective_step" #define CUOPT_MIP_CUT_CHANGE_THRESHOLD "mip_cut_change_threshold" #define CUOPT_MIP_CUT_MIN_ORTHOGONALITY "mip_cut_min_orthogonality" @@ -137,14 +138,16 @@ #define CUOPT_MIP_HYPER_DIVING_SHOW_TYPE "mip_hyper_diving_show_type" /* @brief Recursive sub-MIP (RINS) hyper-parameters */ -#define CUOPT_MIP_HYPER_SUBMIP_BASE_TARGET_FIXRATE "mip_hyper_submip_base_target_fixrate" -#define CUOPT_MIP_HYPER_SUBMIP_MIN_FIXRATE "mip_hyper_submip_min_fixrate" -#define CUOPT_MIP_HYPER_SUBMIP_MIN_FIXRATE_CAP "mip_hyper_submip_min_fixrate_cap" -#define CUOPT_MIP_HYPER_SUBMIP_TARGET_MIP_GAP "mip_hyper_submip_target_mip_gap" -#define CUOPT_MIP_HYPER_SUBMIP_NODE_LIMIT_BASE "mip_hyper_submip_node_limit_base" -#define CUOPT_MIP_HYPER_SUBMIP_MAX_LEVEL "mip_hyper_submip_max_level" -#define CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_RATIO "mip_hyper_submip_iteration_limit_ratio" -#define CUOPT_MIP_HYPER_SUBMIP_ENABLE_CPUFJ "mip_hyper_submip_enable_cpufj" +#define CUOPT_MIP_HYPER_SUBMIP_BASE_TARGET_FIXRATE "mip_hyper_submip_base_target_fixrate" +#define CUOPT_MIP_HYPER_SUBMIP_MIN_FIXRATE "mip_hyper_submip_min_fixrate" +#define CUOPT_MIP_HYPER_SUBMIP_MIN_FIXRATE_CAP "mip_hyper_submip_min_fixrate_cap" +#define CUOPT_MIP_HYPER_SUBMIP_TARGET_MIP_GAP "mip_hyper_submip_target_mip_gap" +#define CUOPT_MIP_HYPER_SUBMIP_NODE_LIMIT_OFFSET "mip_hyper_submip_node_limit_offset" +#define CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_OFFSET "mip_hyper_submip_iteration_limit_offset" +#define CUOPT_MIP_HYPER_SUBMIP_MAX_LEVEL "mip_hyper_submip_max_level" +#define CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_RATIO "mip_hyper_submip_iteration_limit_ratio" +#define CUOPT_MIP_HYPER_SUBMIP_ROUND_CLOSE_RATIO "mip_hyper_submip_round_close_ratio" +#define CUOPT_MIP_HYPER_SUBMIP_ENABLE_CPUFJ "mip_hyper_submip_enable_cpufj" /* @brief Block bounded-variable-elimination step of cuOpt's internal MIP presolve */ #define CUOPT_MIP_HYPER_BLOCK_BVE "mip_hyper_block_bve" diff --git a/cpp/include/cuopt/mathematical_optimization/mip/submip_hyper_params.hpp b/cpp/include/cuopt/mathematical_optimization/mip/submip_hyper_params.hpp index 4464a156a7..3ea98f7ab3 100644 --- a/cpp/include/cuopt/mathematical_optimization/mip/submip_hyper_params.hpp +++ b/cpp/include/cuopt/mathematical_optimization/mip/submip_hyper_params.hpp @@ -12,8 +12,9 @@ */ template struct mip_submip_hyper_params_t { - // Enable or disable (recursive) RINS: -1 automatic, 0 disabled, 1 enabled + // Enable or disable (recursive) RINS/RENS: -1 automatic, 0 disabled, 1 enabled i_t rins = -1; + i_t rens = -1; // Base for calculating the target fix rate for the neighbourhood. Actual target value is // determined automatically according to the success and infeasible rate. @@ -29,7 +30,10 @@ struct mip_submip_hyper_params_t { f_t target_mip_gap = 0.01; // The base node limit for the sub-MIP - i_t node_limit_base = 200; + i_t node_limit_offset = 200; + + // The base iteration limit for the sub-MIP + i_t iteration_limit_offset = 10000; // The current level in the recursion. This is an internal parameter and will set automatically. i_t level = 0; @@ -46,6 +50,10 @@ struct mip_submip_hyper_params_t { // the maximum number of nodes allow for backtracking. i_t dfs_max_backtrack = 5; + // How many variables a single round can fix. Set in terms of ratio of + // (1 - current fixrate). + f_t round_close_ratio = 0.8; + // Run CPU FJ over the sub-MIP bool enable_cpufj = true; }; diff --git a/cpp/src/branch_and_bound/branch_and_bound.cpp b/cpp/src/branch_and_bound/branch_and_bound.cpp index 1454884a0e..29174148b7 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.cpp +++ b/cpp/src/branch_and_bound/branch_and_bound.cpp @@ -213,7 +213,7 @@ inline char feasible_solution_symbol(heuristics_origin_t origin) inline char feasible_solution_symbol(search_strategy_t strategy, bool show_diving) { if (strategy == search_strategy_t::BEST_FIRST) return 'B'; - if (strategy == search_strategy_t::RINS) return 'S'; + if (strategy == search_strategy_t::RINS || strategy == search_strategy_t::RENS) return 'S'; if (!show_diving) return 'D'; switch (strategy) { @@ -225,6 +225,7 @@ inline char feasible_solution_symbol(search_strategy_t strategy, bool show_divin case search_strategy_t::FARKAS_DIVING: return 'F'; case search_strategy_t::VECTOR_LENGTH_DIVING: return 'V'; case search_strategy_t::RINS: return 'S'; + case search_strategy_t::RENS: return 'S'; } return 'U'; @@ -668,7 +669,9 @@ void branch_and_bound_t::set_solution_from_submip( const lp_problem_t& lp, const std::vector& solution, const third_party_presolve_t& presolver, - f_t fixrate) + submip_stats_t& submip_stats, + f_t fixrate, + [[maybe_unused]] std::string_view log_prefix) { bool check_postsolve = false; std::vector leaf_sol; @@ -679,10 +682,14 @@ void branch_and_bound_t::set_solution_from_submip( mutex_original_lp_.lock(); uncrush_primal_solution(original_problem_, lp, leaf_sol, user_sol); mutex_original_lp_.unlock(); - settings_.log.debug_format("SubMIP found a feasible solution with obj={:.4g}", obj); + + DEBUG_SUBMIP("{}Sub-MIP found a feasible solution with obj={:.4g}", + log_prefix, + compute_user_objective(lp, obj)); + bool success = set_solution_from_heuristics(user_sol, heuristics_origin_t::SUBMIP); if (success) { - rins_stats_.save_success(fixrate); + submip_stats.save_success(fixrate); if (settings_.solution_callback != nullptr) { settings_.solution_callback(user_sol, obj); } } } @@ -1033,6 +1040,7 @@ branch_variable_t branch_and_bound_t::variable_selection( return vector_length_diving(worker->leaf_problem, fractional, solution, log); case search_strategy_t::RINS: // This is used for solving the DFS of the sub-MIP. + case search_strategy_t::RENS: branch_var = pc_.variable_selection(fractional, solution); round_dir = martin_criteria(solution[branch_var], worker->root_solution[branch_var]); return {branch_var, round_dir}; @@ -1687,7 +1695,7 @@ void branch_and_bound_t::plunge_with(bfs_worker_t* worker, f_t rel_gap = user_relative_gap(user_obj, user_lower); f_t abs_gap = compute_user_abs_gap(original_lp_, upper_bound, lower_bound); - bool can_launch_rins = true; + bool can_launch_new_submip = true; while (stack.size() > 0 && (solver_status_ == mip_status_t::UNSET && is_running_) && rel_gap > settings_.relative_mip_gap_tol && abs_gap > settings_.absolute_mip_gap_tol) { @@ -1797,7 +1805,9 @@ void branch_and_bound_t::plunge_with(bfs_worker_t* worker, worker->recompute_bounds = node_status != node_status_t::HAS_CHILDREN; if (node_status == node_status_t::HAS_CHILDREN) { - if (can_launch_rins) { can_launch_rins = !launch_rins_worker(worker->leaf_solution.x); } + if (can_launch_new_submip) { + can_launch_new_submip = !launch_submip_worker(worker->leaf_solution.x); + } // The stack should only contain the children of the current parent. // If the stack size is greater than 0, @@ -2127,7 +2137,8 @@ void branch_and_bound_t::dive_with(diving_worker_t* worker, // This is called from the RINS method which already handle the return to the // pool part. Besides, they do not share the same pool. - if (worker->search_strategy != search_strategy_t::RINS) { + if (worker->search_strategy != search_strategy_t::RINS && + worker->search_strategy != search_strategy_t::RENS) { diving_worker_pool_.return_worker_to_pool(worker); } } @@ -2185,18 +2196,19 @@ bool branch_and_bound_t::launch_diving_worker(bfs_worker_t* } template -bool branch_and_bound_t::launch_rins_worker(const std::vector& sol) +bool branch_and_bound_t::launch_submip_worker(const std::vector& sol) { - if (settings_.submip_settings.rins == 0) return false; - if (!incumbent_.has_incumbent) return false; - if (rins_worker_pool_.num_idle() == 0) return false; + if (settings_.submip_settings.rins == 0 && settings_.submip_settings.rens == 0) return false; + if (settings_.submip_settings.rens == 0 && !incumbent_.has_incumbent) return false; + if (submip_worker_pool_.num_idle() == 0) return false; - diving_worker_t* worker = rins_worker_pool_.pop_idle_worker(); + diving_worker_t* worker = submip_worker_pool_.pop_idle_worker(); if (!worker) return false; std::vector current_incumbent; mutex_upper_.lock(); - current_incumbent = incumbent_.x; + bool use_rins = incumbent_.has_incumbent && settings_.submip_settings.rins != 0; + if (use_rins) current_incumbent = incumbent_.x; mutex_upper_.unlock(); // Note that this node does not have the vstatus (it was cleared at the start of B&B exploration) @@ -2205,17 +2217,17 @@ bool branch_and_bound_t::launch_rins_worker(const std::vector& so worker->leaf_problem.lower = original_lp_.lower; worker->leaf_problem.upper = original_lp_.upper; worker->leaf_solution.x = sol; - worker->search_strategy = search_strategy_t::RINS; + worker->search_strategy = use_rins ? search_strategy_t::RINS : search_strategy_t::RENS; worker->set_active(); if (settings_.inside_submip) { // LLVM libomp's GOMP compatibility path skips GCC's firstprivate copy // function for included tasks. - rins(worker, current_incumbent, var_types_); + recursive_submip(worker, current_incumbent, var_types_); } else { #pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) affinity(worker) \ firstprivate(worker, current_incumbent) - rins(worker, current_incumbent, var_types_); + recursive_submip(worker, current_incumbent, var_types_); } return true; @@ -2225,28 +2237,16 @@ template void branch_and_bound_t::solve_submip(diving_worker_t* worker, const std::vector& current_incumbent, const std::vector& var_types, - i_t num_var_fixed, - i_t num_integers, - i_t submip_level, - std::string_view log_prefix, + submip_stats_t& submip_stats, + f_t fixrate, + i_t simplex_iter_used, bool is_root_heuristic) { double start_time = tic(); - std::vector& lower = worker->leaf_problem.lower; - std::vector& upper = worker->leaf_problem.upper; - std::vector& bounds_changed = worker->bounds_changed; - f_t fixrate = (f_t)num_var_fixed / num_integers; - - bool feasible = - worker->node_presolver.bounds_strengthening(settings_, bounds_changed, lower, upper); - - if (!feasible) { - // This should never happen since we are fixing bounds that are already in the incumbent. - rins_stats_.save_infeasible(fixrate); - DEBUG_SUBMIP("{} The problem is infeasible after running bound strengthening!", log_prefix); - return; - } + i_t submip_level = settings_.submip_settings.level + 1; + std::string log_prefix = + std::format("[{} {}] ", search_strategy_to_string(worker->search_strategy), submip_level); f_t user_lower = compute_user_objective(worker->leaf_problem, get_lower_bound()); f_t user_obj = compute_user_objective(worker->leaf_problem, upper_bound_.load()); @@ -2274,24 +2274,30 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke submip_settings.log.log_prefix = log_prefix; #endif - submip_settings.node_limit = settings_.submip_settings.node_limit_base + explored / 20; + submip_settings.node_limit = settings_.submip_settings.node_limit_offset + explored / 20; + + // Add offset only on the top call, we want number of simplex iteration to decay + // as we go down the recursion to avoid spending too much time in the deeper levels. + int64_t iter_offset = + settings_.inside_submip ? 0 : settings_.submip_settings.iteration_limit_offset; + int64_t simplex_iter = exploration_stats_.total_simplex_iters; + f_t iter_ratio = settings_.submip_settings.iteration_limit_ratio; + submip_settings.branch_and_bound_simplex_iteration_limit = - exploration_stats_.total_simplex_iters * settings_.submip_settings.iteration_limit_ratio; + iter_offset + simplex_iter * iter_ratio - simplex_iter_used; + if (submip_settings.branch_and_bound_simplex_iteration_limit <= 0) { return; } + submip_settings.time_limit = settings_.time_limit - toc(exploration_stats_.start_time); - if (submip_settings.time_limit < 0) { return; } + if (submip_settings.time_limit <= 0) { return; } submip_settings.relative_mip_gap_tol = std::min(settings_.submip_settings.target_mip_gap, rel_gap); bool max_recursion = submip_level > settings_.submip_settings.max_level; submip_settings.submip_settings.rins = settings_.submip_settings.rins != 0 && !max_recursion; + submip_settings.submip_settings.rens = settings_.submip_settings.rens != 0 && !max_recursion; - DEBUG_SUBMIP("{}Sub-MIP: num variables fixed={}/{} ({:.2f}%)", - log_prefix, - num_var_fixed, - num_integers, - fixrate * 100); - + DEBUG_SUBMIP("{}Sub-MIP: fixrate={:.2f}", log_prefix, fixrate) DEBUG_SUBMIP( "{}Sub-MIP solve settings: time_limit={:.2f}, node_limit={}, iter_limit={} (current_iter={}), " "tol={:g}", @@ -2318,32 +2324,18 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke if (presolver_status == third_party_presolve_status_t::INFEASIBLE || presolver_status == third_party_presolve_status_t::UNBNDORINFEAS || presolver_status == third_party_presolve_status_t::UNBOUNDED) { - rins_stats_.save_infeasible(fixrate); + DEBUG_SUBMIP("{}Presolve detected infeasibility", log_prefix); + submip_stats.save_infeasible(fixrate); return; } // Also handle optimal if (submip_problem.num_rows == 0 || submip_problem.num_cols == 0) { - DEBUG_SUBMIP("{}Sub-MIP presolved to a trivial {} x {} problem; solving by bound pushing", + DEBUG_SUBMIP("{}Reduced to a trivial {} x {} problem; solving by bound pushing", log_prefix, submip_problem.num_rows, submip_problem.num_cols); - - std::vector reduced_sol(submip_problem.num_cols); - - for (i_t j = 0; j < submip_problem.num_cols; ++j) { - const f_t c = submip_problem.objective[j]; - const f_t l = submip_problem.lower[j]; - const f_t u = submip_problem.upper[j]; - // Minimize c_j x_j over [l, u]; fall back to any finite bound (0 if both are infinite). - if (c < -settings_.zero_tol) { - reduced_sol[j] = std::isfinite(u) ? u : (std::isfinite(l) ? l : 0); - } else { - reduced_sol[j] = std::isfinite(l) ? l : (std::isfinite(u) ? u : 0); - } - } - - set_solution_from_submip(worker->leaf_problem, reduced_sol, presolver, fixrate); + submip_stats.save_empty(); return; } @@ -2355,10 +2347,12 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke submip_settings.heuristic_preemption_callback = nullptr; submip_settings.dual_simplex_objective_callback = nullptr; submip_settings.set_simplex_solution_callback = nullptr; - submip_settings.solution_callback = [this, &presolver, fixrate, worker]( - const std::vector& solution, f_t obj) { - this->set_solution_from_submip(worker->leaf_problem, solution, presolver, fixrate); - }; + submip_settings.solution_callback = + [this, &presolver, fixrate, &submip_stats, log_prefix, worker](const std::vector& solution, + f_t obj) { + this->set_solution_from_submip( + worker->leaf_problem, solution, presolver, submip_stats, fixrate, log_prefix); + }; DEBUG_SUBMIP("{}Sub-MIP: {} constraints, {} variables, {} nonzeros\n", log_prefix, @@ -2370,16 +2364,24 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke branch_and_bound_t submip_bnb(submip_problem, submip_settings, tic(), empty_probing); mip_solution_t submip_solution(submip_problem.num_cols); - // Crush the incumbent to presolve space. It may not be valid for the sub-MIP since we - // may fix integer variables that does not match the current incumbent to reach the target - // fix rate. std::vector presolved_incumbent; - presolver.crush_primal_solution(submip_problem, current_incumbent, presolved_incumbent); - submip_bnb.set_initial_guess(presolved_incumbent); - const f_t user_upper = compute_user_objective(worker->leaf_problem, upper_bound_.load()); - const f_t submip_cutoff = compute_presolved_objective(submip_bnb.original_lp_, user_upper); - submip_bnb.set_initial_upper_bound(submip_cutoff); + // We do not have an incumbent yet, so skip the initial guess. + if (!current_incumbent.empty()) { + // Crush the incumbent to presolve space. It may not be valid for the sub-MIP since we + // may fix integer variables that does not match the current incumbent to reach the target + // fix rate. + presolver.crush_primal_solution(submip_problem, current_incumbent, presolved_incumbent); + submip_bnb.set_initial_guess(presolved_incumbent); + } + + // Even if we do not have a valid incumbent now, the upper bound can still be set by the early + // heuristics. + if (std::isfinite(upper_bound_.load())) { + const f_t user_upper = compute_user_objective(worker->leaf_problem, upper_bound_.load()); + const f_t submip_cutoff = compute_presolved_objective(submip_bnb.original_lp_, user_upper); + submip_bnb.set_initial_upper_bound(submip_cutoff); + } if (!is_root_heuristic) submip_bnb.set_initial_pseudocost(pc_, presolver.get_reduced_to_original_map()); @@ -2401,6 +2403,12 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke fj_cpu_worker_t submip_fj_cpu_worker; if (settings_.submip_settings.enable_cpufj) { + // Since we do not have an incumbent, use the LP solution of the last round of variable fixing + // in RENS. + if (worker->search_strategy == search_strategy_t::RENS) { + presolver.crush_primal_solution(submip_problem, worker->leaf_solution.x, presolved_incumbent); + } + // Launch a CPU FJ worker on the presolved sub-MIP with a fixed budget (in terms of work units) // to run in parallel with the cut-and-branch algorithm with the goal of finding a quick // feasible solution for the sub-MIP problem. The CPU FJ uses the current incumbent (crushed @@ -2443,12 +2451,13 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke if (submip_status == mip_status_t::NUMERICAL) { return; } if (submip_status == mip_status_t::INFEASIBLE || submip_status == mip_status_t::UNBOUNDED) { - rins_stats_.save_infeasible(fixrate); + submip_stats.save_infeasible(fixrate); return; } if (submip_solution.has_incumbent) { - set_solution_from_submip(worker->leaf_problem, submip_solution.x, presolver, fixrate); + set_solution_from_submip( + worker->leaf_problem, submip_solution.x, presolver, submip_stats, fixrate, log_prefix); } // Accumulate simplex iterations to determine when to stop exploring the sub-MIP @@ -2494,6 +2503,8 @@ void get_unfixed_integer_variables(const std::vector& lower, if (std::abs(lower[j] - upper[j]) <= fixed_tol) { continue; } integer_list.push_back(j); } + + assert(!integer_list.empty() && "The integer list cannot be empty!"); } template @@ -2510,38 +2521,68 @@ void fix_variable(i_t j, } template -void apply_rins_fixings(const simplex_solver_settings_t& settings, - const std::vector& current_sol, - const std::vector& fractional, - const std::vector& current_incumbent, - i_t max_var_fixed, - std::vector& lower, - std::vector& upper, - std::vector& bounds_changed, - i_t& num_var_fixed) +i_t apply_rens_fixings(const simplex_solver_settings_t& settings, + const std::vector& node_solution, + const std::vector& integer_list, + i_t target_num_fixed, + std::vector& lower, + std::vector& upper, + std::vector& bounds_changed) { - for (i_t j : fractional) { - if (std::abs(lower[j] - upper[j]) <= settings.fixed_tol) { continue; } + i_t num_fixed = 0; + i_t num_bound_changed = 0; + + for (i_t j : integer_list) { + if (num_fixed >= target_num_fixed) break; + if (std::abs(lower[j] - upper[j]) <= settings.fixed_tol) continue; + f_t old_lower = lower[j]; + f_t old_upper = upper[j]; + lower[j] = std::clamp(std::floor(node_solution[j]), old_lower, old_upper); + upper[j] = std::clamp(std::ceil(node_solution[j]), old_lower, old_upper); + bounds_changed[j] = lower[j] != old_lower || upper[j] != old_upper; + num_bound_changed += bounds_changed[j]; + if (std::abs(lower[j] - upper[j]) <= settings.fixed_tol) ++num_fixed; + } + + return num_bound_changed; +} + +template +i_t apply_rins_fixings(const simplex_solver_settings_t& settings, + const std::vector& current_sol, + const std::vector& integer_list, + const std::vector& current_incumbent, + f_t target_fixrate, + std::vector& lower, + std::vector& upper, + std::vector& bounds_changed) +{ + i_t num_fixed = 0; + i_t target_num_fixed = target_fixrate * integer_list.size(); + + for (i_t j : integer_list) { + if (num_fixed >= target_num_fixed) break; + if (std::abs(lower[j] - upper[j]) <= settings.fixed_tol) continue; if (std::abs(current_sol[j] - current_incumbent[j]) <= settings.integer_tol) { f_t fixed_val = std::round(current_sol[j]); fix_variable(j, lower, upper, bounds_changed, fixed_val); - ++num_var_fixed; - if (num_var_fixed >= max_var_fixed) break; + ++num_fixed; } } + + return num_fixed; } template -void extend_variable_fixings(const simplex_solver_settings_t& settings, - const std::vector& obj_coeffs, - const std::vector& fractional, - const std::vector& current_sol, - const std::vector& root_solution, - i_t max_var_fixed, - std::vector& lower, - std::vector& upper, - std::vector& bounds_changed, - i_t& num_var_fixed) +i_t extend_variable_fixings(const simplex_solver_settings_t& settings, + const std::vector& obj_coeffs, + const std::vector& fractional, + const std::vector& current_sol, + const std::vector& root_solution, + i_t target_num_fixed, + std::vector& lower, + std::vector& upper, + std::vector& bounds_changed) { std::vector> candidates; for (i_t j : fractional) { @@ -2570,43 +2611,72 @@ void extend_variable_fixings(const simplex_solver_settings_t& settings return std::get<0>(a) < std::get<0>(b); }); - f_t change = 0; + i_t num_fixed = 0; + f_t change = 0; + for (auto [dist, j, fixed_val] : candidates) { + if (num_fixed >= target_num_fixed) break; + fix_variable(j, lower, upper, bounds_changed, fixed_val); - ++num_var_fixed; - if (num_var_fixed >= max_var_fixed) break; + ++num_fixed; // Limit the amount of fixing to the current LP. change += dist; - if (change >= 0.5) { break; } + if (change >= 0.5) break; + } + + return num_fixed; +} + +template +f_t calculate_fixrate(const std::vector& integer_list, + const std::vector& lower, + const std::vector& upper, + f_t fixed_tol) +{ + i_t num_fixed = 0; + for (i_t j : integer_list) { + if (std::abs(lower[j] - upper[j]) <= fixed_tol) ++num_fixed; } + + return (f_t)num_fixed / integer_list.size(); } template -void branch_and_bound_t::rins(diving_worker_t* worker, - const std::vector& current_incumbent, - const std::vector& var_types, - bool is_root_heuristic) +void branch_and_bound_t::recursive_submip(diving_worker_t* worker, + const std::vector& current_incumbent, + const std::vector& var_types, + bool is_root_heuristic) { - raft::common::nvtx::range scope("BB::rins_thread"); + raft::common::nvtx::range scope("BB::submip_thread"); if (worker->orbital_fixing) { worker->orbital_fixing->disable(); } - i_t submip_level = settings_.submip_settings.level + 1; - std::string log_prefix = std::format("[RINS {}] ", submip_level); + i_t submip_level = settings_.submip_settings.level + 1; + std::string log_prefix = + std::format("[{} {}] ", search_strategy_to_string(worker->search_strategy), submip_level); + + assert((worker->search_strategy == search_strategy_t::RINS || + worker->search_strategy == search_strategy_t::RENS) && + "Sub-MIP worker must be set to RINS or RENS type"); + + submip_stats_t& submip_stats = + worker->search_strategy == search_strategy_t::RINS ? rins_stats_ : rens_stats_; - ++rins_stats_.total_calls; + ++submip_stats.total_calls; bool has_submip = false; worker->recompute_bounds = false; worker->recompute_basis = true; - branch_and_bound_stats_t rins_stats; + branch_and_bound_stats_t stats; mip_node_t& node = worker->start_node; std::vector& lower = worker->leaf_problem.lower; std::vector& upper = worker->leaf_problem.upper; std::vector& bounds_changed = worker->bounds_changed; std::vector& current_sol = worker->leaf_solution.x; + std::fill(bounds_changed.begin(), bounds_changed.end(), false); + std::vector fractional; i_t num_frac = fractional_variables(settings_, current_sol, var_types, fractional); @@ -2614,102 +2684,86 @@ void branch_and_bound_t::rins(diving_worker_t* worker, get_unfixed_integer_variables(lower, upper, var_types, settings_.fixed_tol, integer_list); i_t num_integers = integer_list.size(); + f_t max_fixrate = submip_get_max_fixrate(submip_stats, settings_.submip_settings, worker->rng); + f_t min_fixrate = settings_.submip_settings.min_fixrate; + f_t fixrate = 0; + f_t close_ratio = settings_.submip_settings.round_close_ratio; - f_t max_fixrate = submip_get_max_fixrate(rins_stats_, settings_.submip_settings, worker->rng); - f_t min_fixrate = std::min(settings_.submip_settings.min_fixrate, max_fixrate); - i_t max_var_fixed = max_fixrate * num_integers; - i_t min_var_fixed = min_fixrate * num_integers; - i_t num_var_fixed = 0; + i_t round = 0; while (solver_status_ == mip_status_t::UNSET && is_running_ && !worker->halt) { - // RINS neighbourhood 1: Fix all the integer variables where the starting solution matches the - // current incumbent, considering only the fractional values in the current node - i_t prev_num_fixed = num_var_fixed; - apply_rins_fixings(settings_, - current_sol, - fractional, - current_incumbent, - max_var_fixed, - lower, - upper, - bounds_changed, - num_var_fixed); - - // Enough variables has been fixed - if (num_var_fixed >= min_var_fixed) { - DEBUG_SUBMIP("{}Fixed {} variables (max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); - has_submip = true; - break; - } - - if (toc(exploration_stats_.start_time) > settings_.time_limit) { - solver_status_ = mip_status_t::TIME_LIMIT; - break; - } - - if (prev_num_fixed == num_var_fixed) { - // RINS neighbourhood 2: Search the entire list of integer variables where the current - // LP solution matches the current incumbent. - apply_rins_fixings(settings_, - current_sol, - integer_list, - current_incumbent, - max_var_fixed, - lower, - upper, - bounds_changed, - num_var_fixed); - - // Enough variables were fixed - if (num_var_fixed >= min_var_fixed) { - DEBUG_SUBMIP("{}Fixed {} variables (max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); + f_t prev_fixrate = fixrate; + f_t distance = 1.0 - (1.0 - prev_fixrate) * close_ratio; + f_t round_target_fixrate = std::min(distance, max_fixrate) - prev_fixrate; + i_t round_target = round_target_fixrate * num_integers; + i_t num_bound_changed = 0; + // Shuffle the fractional and integer list, so every variable has the same chance to the picked + // (we iterate the list in order). + worker->rng.shuffle(integer_list); + worker->rng.shuffle(fractional); + if (worker->search_strategy == search_strategy_t::RINS) { + // RINS neighbourhood: Fix all the integer variables where the current solution matches the + // incumbent. We are using the `max_fixrate` here to allow RINS to fix all integer variables + // that it can within our budget. + num_bound_changed = apply_rins_fixings(settings_, + current_sol, + integer_list, + current_incumbent, + max_fixrate - prev_fixrate, + lower, + upper, + bounds_changed); + + // The RINS neighbourhood ran dry. If it is already tight enough, take it rather than + // diluting it with fixings that do not agree with the incumbent. + if (num_bound_changed == 0 && fixrate >= min_fixrate) { has_submip = true; break; } - // Even considering the entire integer list, we were unable to fix a single variable in this - // iteration. Iterate over the fractional variables again and fixing those that closest to - // an integer solution first in order to reach the fixing threshold. - if (prev_num_fixed == num_var_fixed) { - extend_variable_fixings(settings_, - worker->leaf_problem.objective, - fractional, - current_sol, - worker->root_solution, - max_var_fixed, - lower, - upper, - bounds_changed, - num_var_fixed); - - if (num_var_fixed >= min_var_fixed) { - DEBUG_SUBMIP("{}Fixed {} variables (max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); - has_submip = true; + } else if (worker->search_strategy == search_strategy_t::RENS) { + if (round_target == 0) { + round_target_fixrate = max_fixrate - prev_fixrate; + round_target = round_target_fixrate * num_integers; + if (round_target == 0) { + has_submip = fixrate > 0; break; } + } - if (prev_num_fixed == num_var_fixed) { - DEBUG_SUBMIP("{}Could not fix more variables ({}, max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); - has_submip = true; + num_bound_changed = apply_rens_fixings( + settings_, current_sol, integer_list, round_target, lower, upper, bounds_changed); + } + + // Even considering the entire integer list, we were unable to fix a single variable in this + // iteration. Iterate over the fractional variables again and fixing those that closest to + // an integer solution first in order to reach the fixing threshold. + if (num_bound_changed == 0) { + if (round_target == 0) { + round_target_fixrate = max_fixrate - prev_fixrate; + round_target = round_target_fixrate * num_integers; + if (round_target == 0) { + has_submip = fixrate > 0; break; } } + + num_bound_changed = extend_variable_fixings(settings_, + worker->leaf_problem.objective, + fractional, + current_sol, + worker->root_solution, + round_target, + lower, + upper, + bounds_changed); + + // Even sweep over all integer variables, we exhausted all variables that can be fixed. + // If this is the case, then tries to solve the sub-mip anyway. + if (num_bound_changed == 0) { + has_submip = true; + break; + } } if (toc(exploration_stats_.start_time) > settings_.time_limit) { @@ -2717,14 +2771,64 @@ void branch_and_bound_t::rins(diving_worker_t* worker, break; } + bool is_feasible = + worker->node_presolver.bounds_strengthening(settings_, bounds_changed, lower, upper); + fixrate = calculate_fixrate(integer_list, lower, upper, settings_.fixed_tol); + + DEBUG_SUBMIP( + "{}Round {}: fixed {:.0f} ({:.2f}) -> {:.0f} ({:.2f}) variables. target round fixrate = {} " + "({:.2f}). " + "max fixrate = {:.4g}", + log_prefix, + round, + prev_fixrate * num_integers, + prev_fixrate, + fixrate * num_integers, + fixrate, + round_target, + round_target_fixrate, + max_fixrate); + + if (!is_feasible) { + DEBUG_SUBMIP("{}Round {}: bound strengthening detected infeasibility.", log_prefix, round) + break; + } + + if (fixrate >= max_fixrate) { + has_submip = true; + break; + } + // After fixing the variables, re-solve the LP relaxation. We use the optimal solution // in the next iteration to find additional variable fixings. // We continue to do this until enough variables were fixed or no variable is left to fix. logger_t log; - log.log = false; - dual_status_t lp_status = solve_node_lp(&node, worker, rins_stats, log); + log.log = false; - if (lp_status != dual_status_t::OPTIMAL) { break; } + int64_t iter_offset = + settings_.inside_submip ? 0 : settings_.submip_settings.iteration_limit_offset; + int64_t simplex_iter = exploration_stats_.total_simplex_iters; + f_t iter_ratio = settings_.submip_settings.iteration_limit_ratio; + int64_t simplex_iter_limit = iter_offset + simplex_iter * iter_ratio; + i_t max_iter = std::min(simplex_iter_limit - stats.total_simplex_iters, + std::numeric_limits::max()); + if (max_iter <= 0) { + DEBUG_SUBMIP("{}Round {}: max iteration reached! {}/{}", + log_prefix, + round, + stats.total_simplex_iters.load(), + simplex_iter_limit) + break; + } + + dual_status_t lp_status = solve_node_lp(&node, worker, stats, log, max_iter); + if (lp_status != dual_status_t::OPTIMAL) { + DEBUG_SUBMIP("{}Round {}: simplex returned {}", + log_prefix, + round, + simplex::dual_status_to_string(lp_status)) + break; + } fractional.clear(); num_frac = fractional_variables(settings_, current_sol, var_types, fractional); @@ -2733,25 +2837,41 @@ void branch_and_bound_t::rins(diving_worker_t* worker, node.lower_bound = leaf_obj; snap_to_lattice(&node, leaf_obj); - if (leaf_obj > upper_bound_.load()) { break; } + if (leaf_obj > upper_bound_.load()) { + DEBUG_SUBMIP("{}Round {}: reached cutoff point. obj={:.4g}. upper_bound={:.4g}", + log_prefix, + round, + leaf_obj, + upper_bound_.load()) + break; + } if (num_frac == 0) { - // We found a feasible solution when fixing the variables in RINS. - add_feasible_solution(leaf_obj, current_sol, -1, search_strategy_t::RINS); + // We found a feasible solution when fixing the variables in RINS/RENS. + add_feasible_solution(leaf_obj, current_sol, -1, worker->search_strategy); + DEBUG_SUBMIP("{}Round {}: found a solution with obj={:.4g}. upper_bound={:.4g}", + log_prefix, + round, + leaf_obj, + upper_bound_.load()) break; } worker->recompute_basis = false; + ++round; } - f_t fixrate = (f_t)num_var_fixed / num_integers; + // Accumulate the iterations for sub-MIP so it stops when it reaches the allocated budget. + if (settings_.inside_submip) { + exploration_stats_.total_simplex_iters += stats.total_simplex_iters; + } if (has_submip) { // If not enough variables was fixed (the neighbourhood is too loose) or the sub-MIP already // found a solution that improved the incumbent, then do a DFS with a backtrack_limit of 5 // levels up to try to find a feasible solution quickly from the neighbourhood. if (fixrate < settings_.submip_settings.min_fixrate_cap || - (settings_.inside_submip && rins_stats_.total_success != 0)) { + (settings_.inside_submip && submip_stats.total_success != 0)) { worker->start_node.packed_vstatus = simplex::compress_vstatus(worker->leaf_vstatus); worker->start_lower = lower; worker->start_upper = upper; @@ -2780,7 +2900,11 @@ void branch_and_bound_t::rins(diving_worker_t* worker, // We need the pseudocost to do the DFS, which we do not have during the cut passes. if (!is_root_heuristic) { - DEBUG_SUBMIP("{} Running a quick DFS for the submip!", log_prefix); + DEBUG_SUBMIP("{}Running a quick DFS. fixrate={:.4g} ({}/{})", + log_prefix, + fixrate, + fixrate * num_integers, + num_integers); dive_with(worker, settings_.submip_settings.dfs_max_backtrack); } } @@ -2789,36 +2913,29 @@ void branch_and_bound_t::rins(diving_worker_t* worker, solve_submip(worker, current_incumbent, var_types, - num_var_fixed, - num_integers, - submip_level, - log_prefix, + submip_stats, + fixrate, + stats.total_simplex_iters, is_root_heuristic); } } - // Accumulate the iterations for sub-MIP so it stops when it reaches the allocated budget. - if (settings_.inside_submip) { - exploration_stats_.total_simplex_iters += rins_stats.total_simplex_iters; - } - DEBUG_SUBMIP( - "{}success={}, infeasible={}, calls={}, fixrate={:.4g} ({}), max_fixrate={:.4g} ({}), " - "min_fixrate={:.4g} ({})\n", + "{}success={}, infeasible={}, calls={}, fixrate={:.4g} ({:.0f}/{}), max_fixrate={:.4g}, " + "min_fixrate={:.4g}\n", log_prefix, - rins_stats_.total_success.load(), - rins_stats_.total_infeasible.load(), - rins_stats_.total_calls.load(), + submip_stats.total_success.load(), + submip_stats.total_infeasible.load(), + submip_stats.total_calls.load(), fixrate, - num_var_fixed, + fixrate * num_integers, + num_integers, max_fixrate, - max_var_fixed, - min_fixrate, - min_var_fixed); + min_fixrate); // If the pool is uninitialized (i.e., in the root node), then this just inactivate the worker. if (!is_root_heuristic) { - rins_worker_pool_.return_worker_to_pool(worker); + submip_worker_pool_.return_worker_to_pool(worker); } else { worker->set_inactive(); } @@ -2863,26 +2980,28 @@ void branch_and_bound_t::launch_root_heuristics( } } - if (settings_.submip_settings.rins != 0 && incumbent_.has_incumbent) { + bool use_rins = settings_.submip_settings.rins != 0 && incumbent_.has_incumbent; + if (use_rins || settings_.submip_settings.rens != 0) { + search_strategy_t strategy = use_rins ? search_strategy_t::RINS : search_strategy_t::RENS; diving_worker_t* worker = current_heuristic->create_submip_worker( - cut_pass, lp, settings_, root_objective_, root_vstatus_, sol); + cut_pass, lp, settings_, root_objective_, root_vstatus_, sol, strategy); std::vector current_incumbent; mutex_upper_.lock(); - current_incumbent = incumbent_.x; + if (use_rins) current_incumbent = incumbent_.x; mutex_upper_.unlock(); if (settings_.inside_submip) { // LLVM libomp's GOMP compatibility path skips GCC's firstprivate copy // function for included tasks. - rins(worker, current_incumbent, current_heuristic->var_types_, is_root_heuristic); - + recursive_submip(worker, current_incumbent, current_heuristic->var_types_, is_root_heuristic); } else { ++(*worker_count); #pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) affinity(worker) \ firstprivate(current_incumbent, current_heuristic, worker_count) depend(out : *worker) { - rins(worker, current_incumbent, current_heuristic->var_types_, is_root_heuristic); + recursive_submip( + worker, current_incumbent, current_heuristic->var_types_, is_root_heuristic); --(*worker_count); } } @@ -3794,15 +3913,15 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut settings_, root_relax_soln_.x, edge_norms_); - rins_worker_pool_.init(num_submip_workers, - original_lp_, - Arow_, - var_types_, - symmetry_, - settings_, - root_relax_soln_.x, - edge_norms_, - num_bfs_workers); + submip_worker_pool_.init(num_submip_workers, + original_lp_, + Arow_, + var_types_, + symmetry_, + settings_, + root_relax_soln_.x, + edge_norms_, + num_bfs_workers); if (num_diving_workers > 0) { diving_worker_pool_.init(num_diving_workers, @@ -3857,6 +3976,18 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut if (!std::isfinite(lower_bound)) { lower_bound = search_tree_.root.lower_bound; } } + DEBUG_SUBMIP("RINS: success={}, infeasible={}, empty={}, calls={}", + rins_stats_.total_success.load(), + rins_stats_.total_infeasible.load(), + rins_stats_.total_empty.load(), + rins_stats_.total_calls.load()); + + DEBUG_SUBMIP("RENS: success={}, infeasible={}, empty={}, calls={}", + rens_stats_.total_success.load(), + rens_stats_.total_infeasible.load(), + rens_stats_.total_empty.load(), + rens_stats_.total_calls.load()); + set_final_solution(solution, lower_bound); return solver_status_; } diff --git a/cpp/src/branch_and_bound/branch_and_bound.hpp b/cpp/src/branch_and_bound/branch_and_bound.hpp index fb3f9fce5d..7cf5ed3680 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.hpp +++ b/cpp/src/branch_and_bound/branch_and_bound.hpp @@ -275,8 +275,9 @@ class branch_and_bound_t { diving_worker_pool_t diving_worker_pool_; // Worker pool dedicated to recursive RINS - diving_worker_pool_t rins_worker_pool_; + diving_worker_pool_t submip_worker_pool_; submip_stats_t rins_stats_; + submip_stats_t rens_stats_; // Global status of the solver. omp_atomic_t solver_status_; @@ -369,27 +370,28 @@ class branch_and_bound_t { void dive_with(diving_worker_t* worker, i_t backtrack_limit); // Launch a new RINS worker - bool launch_rins_worker(const std::vector& sol); + bool launch_submip_worker(const std::vector& sol); void set_solution_from_submip(const simplex::lp_problem_t& lp, const std::vector& solution, const third_party_presolve_t& presolver, - f_t fixrate); + submip_stats_t& submip_stats, + f_t fixrate, + std::string_view log_prefix); // Solve the RINS sub-MIP void solve_submip(diving_worker_t* worker, const std::vector& current_incumbent, const std::vector& var_types, - i_t num_var_fixed, - i_t num_integers, - i_t submip_level, - std::string_view log_prefix, + submip_stats_t& submip_stats, + f_t fixrate, + i_t simplex_iter_used, bool is_root_heuristic = false); // Creates and solves the RINS sub-MIP - void rins(diving_worker_t* worker, - const std::vector& current_incumbent, - const std::vector& var_types, - bool is_root_heuristic = false); + void recursive_submip(diving_worker_t* worker, + const std::vector& current_incumbent, + const std::vector& var_types, + bool is_root_heuristic = false); void launch_root_heuristics(const simplex::lp_problem_t& lp, const std::vector& sol, diff --git a/cpp/src/branch_and_bound/constants.hpp b/cpp/src/branch_and_bound/constants.hpp index 2bc94c8f1b..59f43549d8 100644 --- a/cpp/src/branch_and_bound/constants.hpp +++ b/cpp/src/branch_and_bound/constants.hpp @@ -25,6 +25,8 @@ enum class heuristics_origin_t { // [3] E. Danna, E. Rothberg, and C. L. Pape, “Exploring relaxation induced neighborhoods to // improve MIP solutions,” Math. Program., vol. 102, no. 1, pp. 71–90, Jan. 2005, // doi: 10.1007/s10107-004-0518-7. +// [4] T. Berthold, “RENS: The optimal rounding,” Math. Prog. Comp., vol. 6, no. 1, +// pp. 33–54, Mar. 2014, doi: 10.1007/s12532-013-0060-9. enum class search_strategy_t : int { BEST_FIRST = 0, // Best-First + Plunging. PSEUDOCOST_DIVING = 1, // Pseudocost diving [1, Section 9.2.5] @@ -34,6 +36,7 @@ enum class search_strategy_t : int { FARKAS_DIVING = 5, // Farkas Diving (see [2]) VECTOR_LENGTH_DIVING = 6, // Vector Length Diving [1, Section 9.2.6] RINS = 7, // RINS (see [3]) + RENS = 8 // RENS (see [1, Section 9.1.1], [4]) }; enum class branch_direction_t { NONE = -1, DOWN = 0, UP = 1 }; @@ -49,6 +52,7 @@ inline const char* search_strategy_to_string(search_strategy_t search_strategy) case search_strategy_t::FARKAS_DIVING: return "FARKAS_DIVING"; case search_strategy_t::VECTOR_LENGTH_DIVING: return "VECTOR_LENGTH_DIVING"; case search_strategy_t::RINS: return "RINS"; + case search_strategy_t::RENS: return "RENS"; } return "UNKNOWN"; diff --git a/cpp/src/branch_and_bound/worker.hpp b/cpp/src/branch_and_bound/worker.hpp index 50e9a4a206..bbc2836d43 100644 --- a/cpp/src/branch_and_bound/worker.hpp +++ b/cpp/src/branch_and_bound/worker.hpp @@ -263,6 +263,7 @@ struct submip_stats_t { omp_atomic_t total_infeasible = 0; omp_atomic_t infeasible_fixrate_sum = 0; omp_atomic_t total_calls = 0; + omp_atomic_t total_empty = 0; void save_success(double fixrate) { @@ -276,6 +277,7 @@ struct submip_stats_t { infeasible_fixrate_sum += fixrate; } + void save_empty() { ++total_empty; } double average_infeasible_fixrate() const { return infeasible_fixrate_sum / total_infeasible; } double average_success_fixrate() const { return success_fixrate_sum / total_success; } }; diff --git a/cpp/src/math_optimization/solver_settings.cu b/cpp/src/math_optimization/solver_settings.cu index 44f8ac8246..820f41ee3e 100644 --- a/cpp/src/math_optimization/solver_settings.cu +++ b/cpp/src/math_optimization/solver_settings.cu @@ -122,6 +122,7 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings {CUOPT_MIP_HYPER_SUBMIP_MIN_FIXRATE_CAP, &mip_settings.submip_params.min_fixrate_cap, f_t(0.0), f_t(1.0), f_t(0.1), "hard cap on the minimum fix rate for solving a sub-MIP"}, {CUOPT_MIP_HYPER_SUBMIP_TARGET_MIP_GAP, &mip_settings.submip_params.target_mip_gap, f_t(0.0), f_t(1.0), f_t(0.01), "MIP gap target for the sub-MIP"}, {CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_RATIO, &mip_settings.submip_params.iteration_limit_ratio, f_t(0.0), f_t(1.0), f_t(0.8), "sub-MIP simplex-iteration limit as a factor of parent B&B iterations"}, + {CUOPT_MIP_HYPER_SUBMIP_ROUND_CLOSE_RATIO, &mip_settings.submip_params.round_close_ratio, f_t(0.0), f_t(1.0), f_t(0.8), "share of the still-unfixed integers left for later neighbourhood rounds (0 reaches the target fix rate in a single round)"}, }; // Int parameters @@ -149,6 +150,7 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings {CUOPT_MIP_STRONG_CHVATAL_GOMORY_CUTS, &mip_settings.strong_chvatal_gomory_cuts, -1, 1, -1}, {CUOPT_MIP_REDUCED_COST_STRENGTHENING, &mip_settings.reduced_cost_strengthening, -1, std::numeric_limits::max(), -1}, {CUOPT_MIP_RINS, &mip_settings.submip_params.rins, -1, 1, -1}, + {CUOPT_MIP_RENS, &mip_settings.submip_params.rens, -1, 1, -1}, {CUOPT_MIP_OBJECTIVE_STEP, &mip_settings.objective_step, 0, 1, 1}, {CUOPT_NUM_GPUS, &pdlp_settings.num_gpus, -1, 72, 1}, {CUOPT_NUM_GPUS, &mip_settings.num_gpus, -1, 72, 1}, @@ -185,7 +187,8 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings {CUOPT_MIP_HYPER_DIVING_NODE_LIMIT, &mip_settings.diving_params.node_limit, 0, std::numeric_limits::max(), 500, "maximum nodes explored per dive"}, {CUOPT_MIP_HYPER_DIVING_BACKTRACK_LIMIT, &mip_settings.diving_params.backtrack_limit, 0, std::numeric_limits::max(), 5, "maximum backtracking allowed per dive"}, // Recursive sub-MIP (RINS) hyper-parameters (hidden from default --help: name contains "hyper_") - {CUOPT_MIP_HYPER_SUBMIP_NODE_LIMIT_BASE, &mip_settings.submip_params.node_limit_base, 0, std::numeric_limits::max(), 200, "base node limit for the sub-MIP"}, + {CUOPT_MIP_HYPER_SUBMIP_NODE_LIMIT_OFFSET, &mip_settings.submip_params.node_limit_offset, 0, std::numeric_limits::max(), 200, "base node limit for the sub-MIP"}, + {CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_OFFSET, &mip_settings.submip_params.iteration_limit_offset, 0, std::numeric_limits::max(), 10000, "base sub-MIP simplex-iteration limit for root heuristics"}, {CUOPT_MIP_HYPER_SUBMIP_MAX_LEVEL, &mip_settings.submip_params.max_level, 0, std::numeric_limits::max(), 10, "maximum sub-MIP recursion level"}, {CUOPT_BARRIER_PRESOLVE_BOUND_FREE_VARIABLES, &pdlp_settings.barrier_presolve_bound_free_variables, -1, 1, -1, "Bound free variables during barrier presolve: -1 automatic (current default behavior), 0 disabled, 1 enabled"}, // QCQP (barrier) scaling hyper-parameter diff --git a/cpp/src/mip_heuristics/diversity/recombiners/sub_mip.cuh b/cpp/src/mip_heuristics/diversity/recombiners/sub_mip.cuh index 474becef25..95e5c45a8c 100644 --- a/cpp/src/mip_heuristics/diversity/recombiners/sub_mip.cuh +++ b/cpp/src/mip_heuristics/diversity/recombiners/sub_mip.cuh @@ -114,6 +114,7 @@ class sub_mip_recombiner_t : public recombiner_t { branch_and_bound_settings.zero_half_cuts = 0; branch_and_bound_settings.inside_submip = 1; branch_and_bound_settings.submip_settings.rins = 0; + branch_and_bound_settings.submip_settings.rens = 0; branch_and_bound_settings.strong_branching_simplex_iteration_limit = 200; branch_and_bound_settings.solution_callback = [this](std::vector& solution, f_t objective) { diff --git a/cpp/src/mip_heuristics/root_heuristics.hpp b/cpp/src/mip_heuristics/root_heuristics.hpp index f54aff7c1b..1f29b25eef 100644 --- a/cpp/src/mip_heuristics/root_heuristics.hpp +++ b/cpp/src/mip_heuristics/root_heuristics.hpp @@ -58,7 +58,8 @@ struct cut_pass_heuristics_t { const simplex::simplex_solver_settings_t& settings, f_t root_obj, const std::vector& root_vstatus, - const std::vector& sol) + const std::vector& sol, + search_strategy_t type) { submip_worker_ = std::make_unique>( id, lp, Arow_, var_types_, settings, root_solution_, root_edge_norm_); @@ -67,7 +68,7 @@ struct cut_pass_heuristics_t { submip_worker_->leaf_solution.x = sol; submip_worker_->recompute_bounds = false; submip_worker_->recompute_basis = true; - submip_worker_->search_strategy = search_strategy_t::RINS; + submip_worker_->search_strategy = type; submip_worker_->set_active(); return submip_worker_.get(); From c50b7531df5a2df096b00f0ebdfd6a114878439e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Akif=20=C3=87=C3=96RD=C3=9CK?= Date: Tue, 25 Aug 2026 11:04:21 +0200 Subject: [PATCH 14/78] Fix mod2 integrality tolerance (#1770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were checking the tolerance for integrality of rows with relative tolerance, this changes it to absolute tolerance. Authors: - Akif ÇÖRDÜK (https://github.com/akifcorduk) Approvers: - Nicolas L. Guidotti (https://github.com/nguidotti) URL: https://github.com/NVIDIA/cuopt/pull/1770 --- cpp/src/cuts/zero_half_mod2.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cpp/src/cuts/zero_half_mod2.cpp b/cpp/src/cuts/zero_half_mod2.cpp index d0c6a99aea..c34dd162f5 100644 --- a/cpp/src/cuts/zero_half_mod2.cpp +++ b/cpp/src/cuts/zero_half_mod2.cpp @@ -159,6 +159,12 @@ std::vector> find_mod2_row_combinations(const std::vector +bool value_is_integral(f_t value, f_t tolerance) +{ + return std::abs(value - std::round(value)) <= tolerance; +} + template i_t mod2_integral_scale(const inequality_t& inequality, const std::vector& var_types, @@ -177,10 +183,7 @@ i_t mod2_integral_scale(const inequality_t& inequality, f_t scale_work = 1.0; bool integral = true; const f_t scaled_rhs = scale * inequality.rhs; - if (std::abs(scaled_rhs - std::round(scaled_rhs)) > - coefficient_integral_tol * std::max((f_t)1.0, std::abs(scaled_rhs))) { - integral = false; - } + if (!value_is_integral(scaled_rhs, coefficient_integral_tol)) { integral = false; } for (i_t k = 0; integral && k < (i_t)inequality.size(); ++k) { scale_work += 1.0; const i_t j = inequality.index(k); @@ -188,10 +191,7 @@ i_t mod2_integral_scale(const inequality_t& inequality, continue; } const f_t scaled_coefficient = scale * inequality.coeff(k); - if (std::abs(scaled_coefficient - std::round(scaled_coefficient)) > - coefficient_integral_tol * std::max((f_t)1.0, std::abs(scaled_coefficient))) { - integral = false; - } + if (!value_is_integral(scaled_coefficient, coefficient_integral_tol)) { integral = false; } } if (add_work_estimate(scale_work, &work_estimate, max_work_estimate, &work_limit_reached)) { return i_t{0}; From 68d99773f00b805b84602be7439cfb85799cb95d Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Thu, 6 Aug 2026 01:46:47 -0700 Subject: [PATCH 15/78] BHW coefficient reduction pass --- cpp/src/mip_heuristics/CMakeLists.txt | 1 + .../presolve/bhw_coeff_reduce.cpp | 582 ++++++++++++++++++ .../presolve/bhw_coeff_reduce.hpp | 101 +++ .../presolve/third_party_presolve.cpp | 2 + cpp/tests/internal/CMakeLists.txt | 1 + cpp/tests/mip/bhw_coeff_reduce_test.cpp | 171 +++++ 6 files changed, 858 insertions(+) create mode 100644 cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp create mode 100644 cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp create mode 100644 cpp/tests/mip/bhw_coeff_reduce_test.cpp diff --git a/cpp/src/mip_heuristics/CMakeLists.txt b/cpp/src/mip_heuristics/CMakeLists.txt index 6ad1009d84..f54619cfe3 100644 --- a/cpp/src/mip_heuristics/CMakeLists.txt +++ b/cpp/src/mip_heuristics/CMakeLists.txt @@ -13,6 +13,7 @@ set(MIP_LP_NECESSARY_FILES ${CMAKE_CURRENT_SOURCE_DIR}/local_search/rounding/simple_rounding.cu ${CMAKE_CURRENT_SOURCE_DIR}/presolve/third_party_presolve.cpp ${CMAKE_CURRENT_SOURCE_DIR}/presolve/gf2_presolve.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/presolve/bhw_coeff_reduce.cpp ${CMAKE_CURRENT_SOURCE_DIR}/solution/solution.cu ${CMAKE_CURRENT_SOURCE_DIR}/presolve/conflict_graph/clique_table.cu ) diff --git a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp new file mode 100644 index 0000000000..862a5b61cb --- /dev/null +++ b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp @@ -0,0 +1,582 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "bhw_coeff_reduce.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Gordon H. Bradley, Peter L. Hammer, Laurence Wolsey (1974) Coefficient reduction for inequalities +// in 0-1 variables. Mathematical Programming 7:263-282. +// +// Over binaries many inequalities share the same 0/1 feasible set. Theorem 2.5 characterizes that +// set of "equivalent" inequalities by a system over the ceilings and roofs of the row. The test +// here is the same characterization taken over the plain maximal feasible and minimal infeasible +// points, which are supersets of BHW's ceilings and roofs because we skip their ordering condition. +// That costs nothing: with all coefficients positive the activity is monotone, so the maximum of +// w.x over the feasible points is attained at a maximal one and the minimum over the infeasible +// points at a minimal one. Checked exhaustively over 9.4M (row, weight) pairs against full 2^k +// equivalence, in both directions, before landing. +// +// BHW's minimization is not implemented. Section 5 obtains the minimum equivalent inequality by LP +// over the polytope. We instead search weight vectors by increasing max|w| up to a cap, which is +// minimal by construction below the cap, and fall back to two heuristic candidates above it. That +// search is affordable at every width we enumerate only because conditions N1 and N3 restrict +// candidates to non-negative non-increasing sequences: at most sum_{m=1..6} C(11+m, m) = 18563 +// vectors for a 12-entry row, measured at 187us for the worst shape. Lemma 3.6 supplies the lower +// bound that seeds and prunes the search. +// +// TODO: extend the fallback path to the row generation of Section 6. On the PaPILO-reduced corpus +// the LP reaches 1489 reducible rows where these heuristics match the optimum on roughly 60%, and +// unlocks int8 on 321 of them. The separation oracle in step 3 is a 0-1 knapsack, max w.x subject +// to a.x <= b, which solve_knapsack_problem in cuts/cuts.hpp already solves by DP. Note that +// routine is the wrong tool for the LP-strength gate below, which needs the continuous relaxation. +// Boyd (1993) Generating Fenchel Cutting Planes for Knapsack Polyhedra, SIAM J. Optim. +// 3(4):734-750, treats the same separation problem as cut generation. + +namespace cuopt::mathematical_optimization::mip { + +namespace { + +// One row in BHW's normalized frame: condition N1 (every coefficient positive, negatives +// complemented by x_i = 1 - y_i) and condition N3 (coefficients sorted descending). N1 is what +// makes the row activity monotone in x, and it also makes the weights of any equivalent inequality +// non-negative, since a variable that tightens the row must tighten every inequality with the same +// feasible set. Both normalizations are undone before any reduction is returned. +struct norm_row_t { + int k = 0; + std::array coef{}; // descending, all > 0 + std::array slot{}; // position of this entry in the caller's row arrays + std::array flipped{}; // whether this entry was complemented by N1 + int64_t rhs = 0; +}; + +struct partition_t { + std::vector maximal_feasible; + std::vector minimal_infeasible; + // Lemma 3.6: where coef[i] > coef[i+1] and the two variables are not symmetric, every equivalent + // inequality has w_i >= w_{i+1} + 1. Chaining those steps down to w_{k-1} >= 0 bounds max|w|. + std::array strict{}; + std::array suffix_strict{}; + int lemma36_bound = 0; +}; + +int64_t weight_activity(const int64_t* w, uint32_t mask) +{ + int64_t sum = 0; + while (mask != 0u) { + sum += w[std::countr_zero(mask)]; + mask &= mask - 1u; + } + return sum; +} + +// Splits {0,1}^k and collects the maximal feasible and minimal infeasible points. Returns false for +// a degenerate row (all points feasible or all infeasible), which is left to other presolvers. +bool build_partition(const norm_row_t& row, partition_t& out) +{ + const int k = row.k; + const uint32_t n_pat = 1u << k; + cuopt_assert(k >= 2 && k <= BHW_MAX_LEN, "row length outside the enumerable range"); + + std::vector activity(n_pat, 0); + std::vector feasible(n_pat, 0); + uint32_t n_feasible = 0; + for (uint32_t m = 1; m < n_pat; ++m) + activity[m] = activity[m & (m - 1u)] + row.coef[std::countr_zero(m)]; + for (uint32_t m = 0; m < n_pat; ++m) { + feasible[m] = activity[m] <= row.rhs ? 1 : 0; + n_feasible += feasible[m]; + } + if (n_feasible == 0 || n_feasible == n_pat) return false; + + for (uint32_t m = 0; m < n_pat; ++m) { + bool extremal = true; + if (feasible[m] != 0) { + for (int i = 0; i < k && extremal; ++i) + if ((m >> i & 1u) == 0u && feasible[m | (1u << i)] != 0) extremal = false; + if (extremal) out.maximal_feasible.push_back(m); + } else { + for (int i = 0; i < k && extremal; ++i) + if ((m >> i & 1u) != 0u && feasible[m ^ (1u << i)] == 0) extremal = false; + if (extremal) out.minimal_infeasible.push_back(m); + } + } + cuopt_assert(!out.maximal_feasible.empty() && !out.minimal_infeasible.empty(), + "a non-degenerate partition has at least one extremal point on each side"); + + for (int i = 0; i + 1 < k; ++i) { + if (row.coef[i] <= row.coef[i + 1]) continue; + const uint32_t lo_bit = 1u << i; + const uint32_t hi_bit = 1u << (i + 1); + for (uint32_t m = 0; m < n_pat; ++m) { + if ((m & lo_bit) != 0u || (m & hi_bit) == 0u) continue; + // coef[i] > coef[i+1], so moving the set bit down raises the activity: a feasible point whose + // swap is infeasible witnesses that the two variables are not interchangeable. + if (feasible[m] != 0 && feasible[(m ^ hi_bit) | lo_bit] == 0) { + out.strict[i] = true; + break; + } + } + } + for (int i = k - 1; i >= 0; --i) + out.suffix_strict[i] = out.suffix_strict[i + 1] + (out.strict[i] ? 1 : 0); + out.lemma36_bound = out.suffix_strict[0]; + cuopt_assert(out.lemma36_bound < k, "at most k-1 strict steps exist in a row of length k"); + return true; +} + +// BHW Theorem 2.5: (w, t) is equivalent to the row iff every maximal feasible point M satisfies +// sum_{i in M} w_i <= t and every minimal infeasible point satisfies sum_{i in .} w_i >= t + 1. +// Taking t as the feasible-side maximum makes the first block hold by construction, leaving hi < +// lo; integer weights make "> t" and ">= t+1" the same statement. +bool accepts(const partition_t& part, const int64_t* w, int64_t& bound) +{ + int64_t hi = std::numeric_limits::min(); + for (uint32_t m : part.maximal_feasible) + hi = std::max(hi, weight_activity(w, m)); + int64_t lo = std::numeric_limits::max(); + for (uint32_t m : part.minimal_infeasible) + lo = std::min(lo, weight_activity(w, m)); + bound = hi; + return hi < lo; +} + +// The rewritten row must not enlarge this row's LP relaxation: every x in [0,1]^k with w.x <= t has +// to satisfy a.x <= rhs. With a > 0 and w >= 0 after N1 that is a fractional knapsack, max a.x +// subject to w.x <= t, solved by taking the zero-weight entries for free and then filling capacity +// in decreasing a_i/w_i order. Greedy leaves at most one fractional entry, so the comparison closes +// exactly in rationals and no tolerance is needed. The 0-1 knapsack DP in cuts/cuts.hpp cannot be +// substituted here: its optimum is a lower bound on the continuous one, which would let a weakening +// row through. +bool lp_no_weakening(const norm_row_t& row, const int64_t* w, int64_t t) +{ + cuopt_assert(t >= 0, "acceptance implies the origin is feasible, so the bound is non-negative"); + + std::array order{}; + int n_items = 0; + __int128 value = 0; + for (int i = 0; i < row.k; ++i) { + if (w[i] == 0) + value += row.coef[i]; + else + order[n_items++] = i; + } + std::sort(order.begin(), order.begin() + n_items, [&](int x, int y) { + const __int128 dx = (__int128)row.coef[x] * w[y]; + const __int128 dy = (__int128)row.coef[y] * w[x]; + return dx != dy ? dx > dy : x < y; + }); + + int64_t capacity = t; + int fractional = -1; + for (int p = 0; p < n_items; ++p) { + const int i = order[p]; + if (w[i] <= capacity) { + value += row.coef[i]; + capacity -= w[i]; + } else { + if (capacity > 0) fractional = i; + break; + } + } + + if (fractional < 0) return value <= (__int128)row.rhs; + return value * w[fractional] + (__int128)capacity * row.coef[fractional] <= + (__int128)row.rhs * w[fractional]; +} + +// Debug companion to accepts(): checks the 0/1 partition over every point rather than the extremal +// ones the search relies on. +[[maybe_unused]] bool verify_equivalent(const norm_row_t& row, const int64_t* w, int64_t t) +{ + const uint32_t n_pat = 1u << row.k; + for (uint32_t m = 0; m < n_pat; ++m) { + int64_t a_activity = 0; + int64_t w_activity = 0; + for (int i = 0; i < row.k; ++i) { + if ((m >> i & 1u) == 0u) continue; + a_activity += row.coef[i]; + w_activity += w[i]; + } + if ((a_activity <= row.rhs) != (w_activity <= t)) return false; + } + return true; +} + +struct search_state_t { + const norm_row_t* row = nullptr; + const partition_t* part = nullptr; + std::array w{}; + std::array best_w{}; + int64_t best_bound = 0; + int best_nonzeros = 0; + int64_t best_sum = 0; + bool found = false; +}; + +// Enumerates the non-increasing weight vectors reachable from the prefix fixed so far. N1 makes the +// weights non-negative and N3 makes them non-increasing, so a candidate is just a non-increasing +// sequence bounded by w[0]; Lemma 3.6's strict steps both shrink the branching factor and bound how +// low a position may go while still leaving room for the steps beneath it. +void search_positions(search_state_t& st, int pos) +{ + const int k = st.row->k; + if (pos == k) { + int64_t bound = 0; + if (!accepts(*st.part, st.w.data(), bound)) return; + if (!lp_no_weakening(*st.row, st.w.data(), bound)) return; + + int nonzeros = 0; + int64_t sum = 0; + for (int i = 0; i < k; ++i) { + nonzeros += st.w[i] != 0 ? 1 : 0; + sum += st.w[i]; + } + // Same max|w| by construction at this depth, so prefer dropping variables, then smaller + // weights. + if (st.found && + (nonzeros > st.best_nonzeros || (nonzeros == st.best_nonzeros && sum >= st.best_sum))) + return; + st.found = true; + st.best_nonzeros = nonzeros; + st.best_sum = sum; + st.best_bound = bound; + st.best_w = st.w; + return; + } + + const int64_t upper = st.w[pos - 1] - (st.part->strict[pos - 1] ? 1 : 0); + const int64_t lower = st.part->suffix_strict[pos]; + for (int64_t v = upper; v >= lower; --v) { + st.w[pos] = v; + search_positions(st, pos + 1); + } +} + +// Fallback for the rows whose smallest equivalent magnitude exceeds BHW_EXACT_MAX_WEIGHT, where the +// exhaustive search gives up: w = round(a / min a), which was the optimal scale in 131 of 131 +// measured cases, and the all-ones clause form. N3 already sorted a, so both are non-increasing. +bool heuristic_reduce(const norm_row_t& row, + const partition_t& part, + std::vector& weights, + int64_t& bound) +{ + const int k = row.k; + const int64_t a_min = row.coef[k - 1]; + cuopt_assert(a_min > 0, "N1 leaves every coefficient positive"); + + // Only a strict gain is worth installing: smaller magnitude, or the same magnitude with a + // variable dropped. + int64_t best_max = row.coef[0]; + int best_nonzeros = k; + bool found = false; + + std::array candidate{}; + for (int variant = 0; variant < 2; ++variant) { + for (int i = 0; i < k; ++i) + candidate[i] = variant == 0 ? (row.coef[i] + a_min / 2) / a_min : 1; + + int64_t candidate_bound = 0; + if (!accepts(part, candidate.data(), candidate_bound)) continue; + if (!lp_no_weakening(row, candidate.data(), candidate_bound)) continue; + + int64_t candidate_max = 0; + int nonzeros = 0; + for (int i = 0; i < k; ++i) { + candidate_max = std::max(candidate_max, candidate[i]); + nonzeros += candidate[i] != 0 ? 1 : 0; + } + if (candidate_max > best_max || (candidate_max == best_max && nonzeros >= best_nonzeros)) + continue; + + found = true; + best_max = candidate_max; + best_nonzeros = nonzeros; + weights.assign(candidate.begin(), candidate.begin() + k); + bound = candidate_bound; + } + return found; +} + +// Reduce one normalized shape. The caller undoes N1/N3 on the result. +bool reduce_shape(const norm_row_t& row, std::vector& weights, int64_t& bound) +{ + partition_t part; + if (!build_partition(row, part)) return false; + + const int64_t current = row.coef[0]; // N3 puts the largest coefficient first + cuopt_assert(current >= 2, "rows already at magnitude one are rejected before normalization"); + // Lemma 3.6 bounds max|w| from below over every equivalent inequality, so this row is provably + // irreducible in magnitude and not worth searching. + if (part.lemma36_bound >= current) return false; + + search_state_t st; + st.row = &row; + st.part = ∂ + const int64_t m_high = std::min(BHW_EXACT_MAX_WEIGHT, current - 1); + for (int64_t m = std::max(part.lemma36_bound, 1); m <= m_high; ++m) { + st.found = false; + st.w[0] = m; + search_positions(st, 1); + if (!st.found) continue; + // First m with any acceptance, so this is the minimum achievable max|w|. + weights.assign(st.best_w.begin(), st.best_w.begin() + row.k); + bound = st.best_bound; + return true; + } + return heuristic_reduce(row, part, weights, bound); +} + +} // namespace + +template +bhw_row_rewrite_t bhw_reduce_row( + const f_t* coefficients, int len, f_t side, int direction, bhw_shape_cache_t* cache) +{ + cuopt_assert(direction == 1 || direction == -1, + "direction is the sign that orients the row to <="); + bhw_row_rewrite_t rewrite; + if (len < 2 || len > BHW_MAX_LEN) return rewrite; + if (!scaling_bound_finite(side)) return rewrite; + + // Integerize so the point partition is exact, then orient the row to a.x <= b. + const double scale = row_int_scale( + coefficients, len, side, std::numeric_limits::infinity(), BHW_MAX_LEN, BHW_INT_SCALE_MAX); + if (scale == 0.0) return rewrite; + + std::array integral{}; + int64_t largest = 0; + for (int j = 0; j < len; ++j) { + integral[j] = (int64_t)std::llround((double)coefficients[j] * scale) * direction; + largest = std::max(largest, std::abs(integral[j])); + } + // A row already at +/-1 has no magnitude to give back. The census puts most of the corpus here, + // so this test carries the screening cost. + if (largest <= 1) return rewrite; + + norm_row_t norm_row; + norm_row.k = len; + norm_row.rhs = (int64_t)std::llround((double)side * scale) * direction; + std::array order{}; + for (int j = 0; j < len; ++j) { + order[j] = j; + // N1: complementing x_j = 1 - y_j moves the negative coefficient onto the right-hand side. + if (integral[j] < 0) norm_row.rhs -= integral[j]; + } + // N3: descending by magnitude, ties broken by position so the shape key is deterministic. + std::sort(order.begin(), order.begin() + len, [&](int x, int y) { + const int64_t ax = std::abs(integral[x]); + const int64_t ay = std::abs(integral[y]); + return ax != ay ? ax > ay : x < y; + }); + for (int p = 0; p < len; ++p) { + const int j = order[p]; + norm_row.coef[p] = std::abs(integral[j]); + norm_row.slot[p] = j; + norm_row.flipped[p] = integral[j] < 0; + } + + bhw_shape_result_t computed; + const bhw_shape_result_t* result = nullptr; + if (cache != nullptr) { + std::vector key(norm_row.coef.begin(), norm_row.coef.begin() + len); + key.push_back(norm_row.rhs); + auto cached = cache->find(key); + if (cached == cache->end()) { + bhw_shape_result_t fresh; + fresh.accepted = reduce_shape(norm_row, fresh.weights, fresh.bound); + cached = cache->emplace(std::move(key), std::move(fresh)).first; + } + result = &cached->second; + } else { + computed.accepted = reduce_shape(norm_row, computed.weights, computed.bound); + result = &computed; + } + if (!result->accepted) return rewrite; + + cuopt_assert((int)result->weights.size() == len, "cached shape has the wrong length"); + cuopt_assert(*std::min_element(result->weights.begin(), result->weights.end()) >= 0, + "N1 leaves the reduced weights non-negative"); + cuopt_assert( + *std::max_element(result->weights.begin(), result->weights.end()) == result->weights[0], + "N3 leaves the reduced weights non-increasing"); + cuopt_assert(result->weights[0] < norm_row.coef[0] || + std::count(result->weights.begin(), result->weights.end(), (int64_t)0) > 0, + "an accepted rewrite must shrink the magnitude or drop a variable"); + cuopt_assert(verify_equivalent(norm_row, result->weights.data(), result->bound), + "BHW rewrite changed the 0/1 feasible set"); + + // Undo N3 and N1, then undo the orientation. Complementing back turns w_i y_i into w_i - w_i x_i, + // which flips the coefficient and moves w_i onto the bound. + rewrite.coefficients.assign(len, 0); + int64_t new_side = result->bound; + for (int p = 0; p < len; ++p) { + const int j = norm_row.slot[p]; + if (norm_row.flipped[p]) { + rewrite.coefficients[j] = -result->weights[p]; + new_side -= result->weights[p]; + } else { + rewrite.coefficients[j] = result->weights[p]; + } + } + for (int j = 0; j < len; ++j) + rewrite.coefficients[j] *= direction; + rewrite.side = new_side * direction; + rewrite.max_coef_before = norm_row.coef[0]; + rewrite.max_coef_after = result->weights[0]; + rewrite.accepted = true; + return rewrite; +} +template +papilo::PresolveStatus BHWCoeffReduce::execute(const papilo::Problem& problem, + const papilo::ProblemUpdate& problemUpdate, + const papilo::Num& num, + papilo::Reductions& reductions, + const papilo::Timer& timer, + int& reason_of_infeasibility) +{ + const auto& constraint_matrix = problem.getConstraintMatrix(); + const auto& lhs_values = constraint_matrix.getLeftHandSides(); + const auto& rhs_values = constraint_matrix.getRightHandSides(); + const auto& row_flags = constraint_matrix.getRowFlags(); + const auto& domains = problem.getVariableDomains(); + const auto& col_flags = domains.flags; + const auto& lower_bounds = domains.lower_bounds; + const auto& upper_bounds = domains.upper_bounds; + + const int num_rows = constraint_matrix.getNRows(); + papilo::PresolveStatus status = papilo::PresolveStatus::kUnchanged; + int64_t coefficients_reduced = 0; + int64_t coefficients_dropped = 0; + std::vector row_shrinks; + + // Every eligible row is screened, not only problemUpdate.getChangedActivities(): that worklist is + // seeded in full once and afterwards fed only by activity changes, so a row whose side changes is + // never revisited. Rescreening is cheap because the shape cache absorbs the repetition and the + // reduction is idempotent. + for (int row = 0; row < num_rows; ++row) { + auto row_coefficients = constraint_matrix.getRowCoefficients(row); + const int len = row_coefficients.getLength(); + if (len < 2 || len > BHW_MAX_LEN) continue; + + const auto& row_flag = row_flags[row]; + if (row_flag.test(papilo::RowFlag::kRedundant)) continue; + const bool lhs_infinite = row_flag.test(papilo::RowFlag::kLhsInf); + const bool rhs_infinite = row_flag.test(papilo::RowFlag::kRhsInf); + // Equal flags mean either a ranged row / equation (both sides finite) or a free row. + if (lhs_infinite == rhs_infinite) continue; + + const int* indices = row_coefficients.getIndices(); + const f_t* values = row_coefficients.getValues(); + bool all_binary = true; + for (int j = 0; j < len && all_binary; ++j) { + const int col = indices[j]; + all_binary = col_flags[col].test(papilo::ColFlag::kIntegral) && + !col_flags[col].test(papilo::ColFlag::kLbInf) && + !col_flags[col].test(papilo::ColFlag::kUbInf) && + !col_flags[col].test(papilo::ColFlag::kFixed) && num.isZero(lower_bounds[col]) && + num.isEq(upper_bounds[col], f_t{1}); + } + if (!all_binary) continue; + + const int direction = lhs_infinite ? 1 : -1; + const f_t side = lhs_infinite ? rhs_values[row] : lhs_values[row]; + const bhw_row_rewrite_t rewrite = + bhw_reduce_row(values, len, side, direction, &shape_cache_); + if (!rewrite.accepted) continue; + + cuopt_assert(rewrite.max_coef_after >= 1, + "an accepted rewrite keeps at least one nonzero weight"); + row_shrinks.push_back((double)rewrite.max_coef_before / (double)rewrite.max_coef_after); + + // Same shape as papilo's own sparsifier, SimplifyInequalities: lock the row, then the entries, + // then the side. Dropping an entry needs no column lock -- ProblemUpdate marks the column + // modified and derives the resulting singleton rows and empty columns itself. The zero has to + // be exact: SparseStorage::changeRowInplace compacts an entry out on newval == 0, not on + // num.isZero. + papilo::TransactionGuard guard{reductions}; + reductions.lockRow(row); + [[maybe_unused]] int emitted = 0; + for (int j = 0; j < len; ++j) { + if ((f_t)rewrite.coefficients[j] == values[j]) continue; + reductions.changeMatrixEntry(row, indices[j], (f_t)rewrite.coefficients[j]); + ++emitted; + ++coefficients_reduced; + coefficients_dropped += rewrite.coefficients[j] == 0; + } + if (direction == 1) { + if ((f_t)rewrite.side != rhs_values[row]) { + reductions.changeRowRHS(row, (f_t)rewrite.side); + ++emitted; + } + } else { + if ((f_t)rewrite.side != lhs_values[row]) { + reductions.changeRowLHS(row, (f_t)rewrite.side); + ++emitted; + } + } + // Reporting kReduced for a transaction that changes nothing would have papilo re-derive the + // same rewrite every round. + cuopt_assert(emitted > 0, "accepted rewrite emitted no reduction"); + status = papilo::PresolveStatus::kReduced; + } + + if (coefficients_reduced > 0) { + const size_t n_rows_rewritten = row_shrinks.size(); + cuopt_assert(n_rows_rewritten > 0, "a changed coefficient implies an accepted row"); + const double mean = + std::accumulate(row_shrinks.begin(), row_shrinks.end(), 0.0) / (double)n_rows_rewritten; + // Mean alone is outlier-driven here: one row collapsing from a large coefficient to 1 outweighs + // hundreds of modest rewrites, so report the median next to it. + const auto middle = row_shrinks.begin() + n_rows_rewritten / 2; + std::nth_element(row_shrinks.begin(), middle, row_shrinks.end()); + double median = *middle; + if (n_rows_rewritten % 2 == 0) + median = (median + *std::max_element(row_shrinks.begin(), middle)) / 2.0; + + CUOPT_LOG_DEBUG( + "BHW reduced %ld coefficients (%ld dropped) in %zu rows, " + "max|a| shrank %.1fx mean, %.1fx median", + coefficients_reduced, + coefficients_dropped, + n_rows_rewritten, + mean, + median); + } + + return status; +} + +#define INSTANTIATE(F_TYPE) \ + template class BHWCoeffReduce; \ + template bhw_row_rewrite_t bhw_reduce_row( \ + const F_TYPE*, int, F_TYPE, int, bhw_shape_cache_t*); + +#if MIP_INSTANTIATE_FLOAT || PDLP_INSTANTIATE_FLOAT +INSTANTIATE(float) +#endif + +#if MIP_INSTANTIATE_DOUBLE +INSTANTIATE(double) +#endif + +#undef INSTANTIATE + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp new file mode 100644 index 0000000000..f01892b652 --- /dev/null +++ b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp @@ -0,0 +1,101 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#if !defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wstringop-overflow" // ignore boost error for pip wheel build +#pragma GCC diagnostic ignored "-Wnarrowing" +#endif +#include +#include +#include +#include +#if !defined(__clang__) +#pragma GCC diagnostic pop +#endif + +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +// Widest row we enumerate: building the point partition walks 2^BHW_MAX_LEN patterns. A census of +// the PaPILO-reduced MIPLIB corpus (6.2M all-binary one-sided rows) puts 98.0% of the exactly +// integralizable ones at nnz <= 12; raising this to 40 would add 2.0%. +static constexpr int BHW_MAX_LEN = 12; +// Largest max|w| the exhaustive search considers before falling back to the heuristic candidates. +// Accepted weights were at most 3 throughout the corpus study. +static constexpr int64_t BHW_EXACT_MAX_WEIGHT = 6; +// Largest per-row rational multiplier / denominator used to integerize a row (passed to +// row_int_scale as its maxdnom/maxfinal caps). +static constexpr int64_t BHW_INT_SCALE_MAX = 1000000; // 1e6 + +// Outcome for one canonical row shape, in BHW's normalized frame (coefficients complemented to be +// positive and sorted descending). Rejections are cached too, since re-deriving them is the bulk of +// the work on instances whose rows repeat. +struct bhw_shape_result_t { + std::vector weights; + int64_t bound = 0; + bool accepted = false; +}; + +// Keyed by the normalized coefficients followed by the normalized right-hand side. The reduction is +// a pure function of that key, so entries stay valid across presolve rounds and problems. +using bhw_shape_cache_t = std::map, bhw_shape_result_t>; + +struct bhw_row_rewrite_t { + std::vector coefficients; // one per input entry, in input order; 0 drops that entry + int64_t side = 0; // replaces the row's finite side + // Largest |coefficient| before and after reduction, both in the integerized frame. Comparable + // only there: the row is scaled on the way in, so the input coefficients sit in a different + // frame. + int64_t max_coef_before = 0; + int64_t max_coef_after = 0; + bool accepted = false; +}; + +// Rewrite one one-sided all-binary row with smaller integer coefficients spanning the same 0/1 +// feasible set. direction is +1 for "coefficients . x <= side" and -1 for ">= side"; side is the +// finite side of the row. Rejects the row (accepted = false) unless it integerizes exactly, admits +// a strictly smaller equivalent form, and that form does not enlarge the row's LP relaxation. cache +// may be null to skip memoization. +// +// The caller checks that every entry is a binary integer variable and that exactly one side of the +// row is finite. Exposed for testing: BHWCoeffReduce::execute only screens rows and emits the +// result, so this covers the whole reduction without any papilo types. +template +bhw_row_rewrite_t bhw_reduce_row( + const f_t* coefficients, int len, f_t side, int direction, bhw_shape_cache_t* cache); + +// Bradley-Hammer-Wolsey coefficient reduction: replace an all-binary row by an equivalent one with +// smaller integer coefficients. See bhw_coeff_reduce.cpp for the lineage. +template +class BHWCoeffReduce : public papilo::PresolveMethod { + public: + BHWCoeffReduce() : papilo::PresolveMethod() + { + this->setName("bhwcoeffreduce"); + this->setType(papilo::PresolverType::kIntegralCols); + this->setTiming(papilo::PresolverTiming::kMedium); + } + + papilo::PresolveStatus execute(const papilo::Problem& problem, + const papilo::ProblemUpdate& problemUpdate, + const papilo::Num& num, + papilo::Reductions& reductions, + const papilo::Timer& timer, + int& reason_of_infeasibility) override; + + private: + // Only touched from execute, which papilo runs one task at a time per presolver object. + bhw_shape_cache_t shape_cache_; +}; + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp b/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp index 3d5b165abb..10784f7021 100644 --- a/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp +++ b/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -677,6 +678,7 @@ void set_presolve_methods( if (category == problem_category_t::MIP) { // cuOpt custom GF2 presolver maybe_add(uptr(new cuopt::mathematical_optimization::mip::GF2Presolve())); + maybe_add(uptr(new cuopt::mathematical_optimization::mip::BHWCoeffReduce())); } // fast presolvers maybe_add(uptr(new papilo::SingletonCols())); diff --git a/cpp/tests/internal/CMakeLists.txt b/cpp/tests/internal/CMakeLists.txt index 9b5cd2ecb7..99f26aa440 100644 --- a/cpp/tests/internal/CMakeLists.txt +++ b/cpp/tests/internal/CMakeLists.txt @@ -28,6 +28,7 @@ ConfigureTest(NUMOPT_INTERNAL_TEST ${CUOPT_TEST_DIR}/mip/empty_fixed_problems_test.cu ${CUOPT_TEST_DIR}/mip/presolve_test.cu ${CUOPT_TEST_DIR}/mip/block_bve_test.cu + ${CUOPT_TEST_DIR}/mip/bhw_coeff_reduce_test.cpp ${CUOPT_TEST_DIR}/mip/gf2_presolve_test.cpp ${CUOPT_TEST_DIR}/mip/termination_test.cu ${CUOPT_TEST_DIR}/mip/determinism_test.cu diff --git a/cpp/tests/mip/bhw_coeff_reduce_test.cpp b/cpp/tests/mip/bhw_coeff_reduce_test.cpp new file mode 100644 index 0000000000..432bf2d078 --- /dev/null +++ b/cpp/tests/mip/bhw_coeff_reduce_test.cpp @@ -0,0 +1,171 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include + +#include + +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::test { + +using mip::BHW_MAX_LEN; +using mip::bhw_reduce_row; +using mip::bhw_row_rewrite_t; +using mip::bhw_shape_cache_t; + +namespace { + +bhw_row_rewrite_t reduce(const std::vector& coefficients, + double side, + int direction = 1, + bhw_shape_cache_t* cache = nullptr) +{ + return bhw_reduce_row( + coefficients.data(), (int)coefficients.size(), side, direction, cache); +} + +// The whole point of the pass: the rewritten row must accept exactly the same 0/1 points as the +// original. Checked over every point, independently of the extremal-point test the search uses. +bool same_feasible_set(const std::vector& coefficients, + double side, + int direction, + const bhw_row_rewrite_t& rewrite) +{ + constexpr double tol = 1e-9; + const int k = (int)coefficients.size(); + for (uint32_t mask = 0; mask < (1u << k); ++mask) { + double original = 0.0; + int64_t rewritten = 0; + for (int i = 0; i < k; ++i) { + if ((mask >> i & 1u) == 0u) continue; + original += coefficients[i]; + rewritten += rewrite.coefficients[i]; + } + const bool original_ok = direction == 1 ? original <= side + tol : original >= side - tol; + const bool rewritten_ok = + direction == 1 ? rewritten <= rewrite.side : rewritten >= rewrite.side; + if (original_ok != rewritten_ok) return false; + } + return true; +} + +} // namespace + +// Bradley, Hammer and Wolsey (1974) open with this row and reduce it to 4,4,2,2,1,1,1,0 <= 5. That +// rewrite enlarges the LP relaxation -- it admits a fractional point of activity 91.75 against a +// right-hand side of 80 -- so the LP-strength check rejects it and no smaller equivalent form +// survives. +TEST(bhw_coeff_reduce, rejects_a_rewrite_that_weakens_the_relaxation) +{ + EXPECT_FALSE(reduce({65, 64, 41, 22, 13, 12, 8, 2}, 80).accepted); +} + +// The shape that motivated the pass: bnatt400 leaves rows whose coefficients are 9/10 and 1/3 +// against a right-hand side of 2/3. Integerizing and reducing lands them in int8 range. +TEST(bhw_coeff_reduce, rational_row_integerizes_and_reduces) +{ + const std::vector row{0.9, 1.0 / 3, 1.0 / 3, 1.0 / 3}; + const auto reduced = reduce(row, 2.0 / 3); + ASSERT_TRUE(reduced.accepted); + EXPECT_EQ(reduced.coefficients, std::vector({3, 1, 1, 1})); + EXPECT_EQ(reduced.side, 2); + EXPECT_TRUE(same_feasible_set(row, 2.0 / 3, 1, reduced)); +} + +// The >= orientation is normalized by negation, so the same row negated must come back negated. +TEST(bhw_coeff_reduce, greater_equal_row_keeps_its_orientation) +{ + const std::vector row{-0.9, -1.0 / 3, -1.0 / 3, -1.0 / 3}; + const auto reduced = reduce(row, -2.0 / 3, -1); + ASSERT_TRUE(reduced.accepted); + EXPECT_EQ(reduced.coefficients, std::vector({-3, -1, -1, -1})); + EXPECT_EQ(reduced.side, -2); + EXPECT_TRUE(same_feasible_set(row, -2.0 / 3, -1, reduced)); +} + +TEST(bhw_coeff_reduce, rejects_rows_with_nothing_to_give_back) +{ + // Already at unit magnitude. + EXPECT_FALSE(reduce({1, 1, 1, 1}, 2).accepted); + // Does not integerize within the rational cap. + EXPECT_FALSE(reduce({M_PI, 1, 1}, 2).accepted); + // Outside the enumerable width. + EXPECT_FALSE(reduce({5}, 2).accepted); + EXPECT_FALSE(reduce(std::vector(BHW_MAX_LEN + 1, 3.0), 5).accepted); + // Every point feasible, so there is nothing to separate. + EXPECT_FALSE(reduce({3, 2, 2}, 100).accepted); + // No point feasible. + EXPECT_FALSE(reduce({3, 2, 2}, -1).accepted); +} + +TEST(bhw_coeff_reduce, memoized_result_matches_the_uncached_one) +{ + bhw_shape_cache_t cache; + const std::vector> rows{ + {0.9, 1.0 / 3, 1.0 / 3, 1.0 / 3}, {6, 4, 3, 2}, {-6, 4, 3, -2}, {9, 7, 6, 6, 4}}; + for (const auto& row : rows) { + for (int repeat = 0; repeat < 2; ++repeat) { + const auto cached = reduce(row, 12, 1, &cache); + const auto uncached = reduce(row, 12, 1, nullptr); + EXPECT_EQ(cached.accepted, uncached.accepted); + EXPECT_EQ(cached.coefficients, uncached.coefficients); + EXPECT_EQ(cached.side, uncached.side); + } + } +} + +// The invariant that matters, over mixed signs, both orientations and rational coefficients: an +// accepted rewrite never changes which 0/1 points satisfy the row. +TEST(bhw_coeff_reduce, accepted_rewrites_preserve_the_feasible_set) +{ + std::mt19937_64 rng(20260805); + bhw_shape_cache_t cache; + const int denominators[] = {1, 2, 3, 4, 5, 6, 8, 10, 12, 16}; + int accepted = 0; + + for (int trial = 0; trial < 20000; ++trial) { + const int len = 2 + (int)(rng() % 7); + const int direction = (rng() & 1u) != 0u ? 1 : -1; + const int denominator = denominators[rng() % 10]; + + std::vector row(len); + double positive_sum = 0.0; + double negative_sum = 0.0; + for (int i = 0; i < len; ++i) { + const int64_t numerator = 1 + (int64_t)(rng() % 30); + row[i] = (double)numerator / denominator * ((rng() & 3u) == 0u ? -1.0 : 1.0); + if (row[i] > 0.0) + positive_sum += row[i]; + else + negative_sum += row[i]; + } + // Put the side inside the activity range so the row is not trivially satisfied or violated. + double side = negative_sum + (positive_sum - negative_sum) * (double)(rng() % 1001) / 1000.0; + side = std::round(side * denominator) / denominator; + if (direction == -1) { + for (double& value : row) + value = -value; + side = -side; + } + + const auto reduced = reduce(row, side, direction, &cache); + if (!reduced.accepted) continue; + ++accepted; + + ASSERT_EQ((int)reduced.coefficients.size(), len); + ASSERT_TRUE(same_feasible_set(row, side, direction, reduced)) + << "rewrite changed the 0/1 feasible set on trial " << trial; + } + // Guards against the generator drifting into a corner where nothing is ever reduced. + EXPECT_GT(accepted, 1000); +} + +} // namespace cuopt::mathematical_optimization::test From 880064c009ed22c43e7986c3e4016285028fb887 Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Thu, 6 Aug 2026 08:19:10 -0700 Subject: [PATCH 16/78] cleanup --- cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp | 12 +++++++++++- cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp | 6 +++--- cpp/tests/mip/bhw_coeff_reduce_test.cpp | 7 +++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp index 862a5b61cb..5685018d56 100644 --- a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp +++ b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp @@ -365,7 +365,8 @@ bhw_row_rewrite_t bhw_reduce_row( int64_t largest = 0; for (int j = 0; j < len; ++j) { integral[j] = (int64_t)std::llround((double)coefficients[j] * scale) * direction; - largest = std::max(largest, std::abs(integral[j])); + if (integral[j] == 0) return rewrite; + largest = std::max(largest, std::abs(integral[j])); } // A row already at +/-1 has no magnitude to give back. The census puts most of the corpus here, // so this test carries the screening cost. @@ -444,6 +445,8 @@ bhw_row_rewrite_t bhw_reduce_row( rewrite.accepted = true; return rewrite; } +static constexpr int BHW_INTERRUPT_CHECK_STRIDE = 256; + template papilo::PresolveStatus BHWCoeffReduce::execute(const papilo::Problem& problem, const papilo::ProblemUpdate& problemUpdate, @@ -460,6 +463,7 @@ papilo::PresolveStatus BHWCoeffReduce::execute(const papilo::Problem& const auto& col_flags = domains.flags; const auto& lower_bounds = domains.lower_bounds; const auto& upper_bounds = domains.upper_bounds; + const auto& presolve_options = problemUpdate.getPresolveOptions(); const int num_rows = constraint_matrix.getNRows(); papilo::PresolveStatus status = papilo::PresolveStatus::kUnchanged; @@ -472,6 +476,12 @@ papilo::PresolveStatus BHWCoeffReduce::execute(const papilo::Problem& // never revisited. Rescreening is cheap because the shape cache absorbs the repetition and the // reduction is idempotent. for (int row = 0; row < num_rows; ++row) { + if (reductions.size() >= presolve_options.max_reduction_seq) break; + if (row % BHW_INTERRUPT_CHECK_STRIDE == 0 && + papilo::PresolveMethod::is_interrupted( + timer, presolve_options.tlim, presolve_options.early_exit_callback)) + break; + auto row_coefficients = constraint_matrix.getRowCoefficients(row); const int len = row_coefficients.getLength(); if (len < 2 || len > BHW_MAX_LEN) continue; diff --git a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp index f01892b652..d62696f629 100644 --- a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp +++ b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp @@ -63,9 +63,9 @@ struct bhw_row_rewrite_t { // Rewrite one one-sided all-binary row with smaller integer coefficients spanning the same 0/1 // feasible set. direction is +1 for "coefficients . x <= side" and -1 for ">= side"; side is the -// finite side of the row. Rejects the row (accepted = false) unless it integerizes exactly, admits -// a strictly smaller equivalent form, and that form does not enlarge the row's LP relaxation. cache -// may be null to skip memoization. +// finite side of the row. Rejects the row (accepted = false) unless it integerizes exactly to +// nonzero coefficients, admits a strictly smaller equivalent form, and that form does not enlarge +// the row's LP relaxation. cache may be null to skip memoization. // // The caller checks that every entry is a binary integer variable and that exactly one side of the // row is finite. Exposed for testing: BHWCoeffReduce::execute only screens rows and emits the diff --git a/cpp/tests/mip/bhw_coeff_reduce_test.cpp b/cpp/tests/mip/bhw_coeff_reduce_test.cpp index 432bf2d078..7dd6769b6d 100644 --- a/cpp/tests/mip/bhw_coeff_reduce_test.cpp +++ b/cpp/tests/mip/bhw_coeff_reduce_test.cpp @@ -106,6 +106,13 @@ TEST(bhw_coeff_reduce, rejects_rows_with_nothing_to_give_back) EXPECT_FALSE(reduce({3, 2, 2}, -1).accepted); } +TEST(bhw_coeff_reduce, rejects_rows_with_a_zero_coefficient) +{ + EXPECT_FALSE(reduce({65, 64, 41, 22, 13, 12, 8, 2, 0}, 80).accepted); + EXPECT_FALSE(reduce({0, 9, 7, 6, 6, 4}, 20).accepted); + EXPECT_FALSE(reduce({6, 0}, 5).accepted); +} + TEST(bhw_coeff_reduce, memoized_result_matches_the_uncached_one) { bhw_shape_cache_t cache; From ad0a45c6801b2563cea99964cf7c91249a9c0f9a Mon Sep 17 00:00:00 2001 From: yboucher Date: Wed, 19 Aug 2026 06:34:14 -0700 Subject: [PATCH 17/78] cleanup --- .../presolve/bhw_coeff_reduce.cpp | 124 +++++++++++------- .../presolve/bhw_coeff_reduce.hpp | 8 +- cpp/tests/mip/bhw_coeff_reduce_test.cpp | 8 +- 3 files changed, 84 insertions(+), 56 deletions(-) diff --git a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp index 5685018d56..4d6de0c8d4 100644 --- a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp +++ b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp @@ -31,29 +31,28 @@ // points, which are supersets of BHW's ceilings and roofs because we skip their ordering condition. // That costs nothing: with all coefficients positive the activity is monotone, so the maximum of // w.x over the feasible points is attained at a maximal one and the minimum over the infeasible -// points at a minimal one. Checked exhaustively over 9.4M (row, weight) pairs against full 2^k -// equivalence, in both directions, before landing. +// points at a minimal one. // // BHW's minimization is not implemented. Section 5 obtains the minimum equivalent inequality by LP // over the polytope. We instead search weight vectors by increasing max|w| up to a cap, which is // minimal by construction below the cap, and fall back to two heuristic candidates above it. That // search is affordable at every width we enumerate only because conditions N1 and N3 restrict // candidates to non-negative non-increasing sequences: at most sum_{m=1..6} C(11+m, m) = 18563 -// vectors for a 12-entry row, measured at 187us for the worst shape. Lemma 3.6 supplies the lower -// bound that seeds and prunes the search. +// vectors for a 12-entry row. Lemma 3.6 supplies the lower bound that seeds and prunes the search. // -// TODO: extend the fallback path to the row generation of Section 6. On the PaPILO-reduced corpus -// the LP reaches 1489 reducible rows where these heuristics match the optimum on roughly 60%, and -// unlocks int8 on 321 of them. The separation oracle in step 3 is a 0-1 knapsack, max w.x subject -// to a.x <= b, which solve_knapsack_problem in cuts/cuts.hpp already solves by DP. Note that -// routine is the wrong tool for the LP-strength gate below, which needs the continuous relaxation. -// Boyd (1993) Generating Fenchel Cutting Planes for Knapsack Polyhedra, SIAM J. Optim. -// 3(4):734-750, treats the same separation problem as cut generation. +// TODO: extend the fallback path to the row generation of Section 6, which reaches rows the two +// heuristics below leave at a larger magnitude than necessary. The separation oracle in step 3 is a +// 0-1 knapsack, max w.x subject to a.x <= b, which solve_knapsack_problem in cuts/cuts.hpp already +// solves by DP. Boyd (1993) Generating Fenchel Cutting Planes for Knapsack Polyhedra, SIAM J. +// Optim. 3(4):734-750, treats the same separation problem as cut generation. namespace cuopt::mathematical_optimization::mip { namespace { +// Rows between two interrupt checks in execute; screening one row is cheap next to the check. +constexpr int BHW_INTERRUPT_CHECK_STRIDE = 256; + // One row in BHW's normalized frame: condition N1 (every coefficient positive, negatives // complemented by x_i = 1 - y_i) and condition N3 (coefficients sorted descending). N1 is what // makes the row activity monotone in x, and it also makes the weights of any equivalent inequality @@ -271,8 +270,9 @@ void search_positions(search_state_t& st, int pos) } // Fallback for the rows whose smallest equivalent magnitude exceeds BHW_EXACT_MAX_WEIGHT, where the -// exhaustive search gives up: w = round(a / min a), which was the optimal scale in 131 of 131 -// measured cases, and the all-ones clause form. N3 already sorted a, so both are non-increasing. +// exhaustive search gives up: w = round(a / min a) and the all-ones clause form, both put through +// the same acceptance and LP-strength gates as a searched vector. N3 already sorted a, so both +// candidates are non-increasing. bool heuristic_reduce(const norm_row_t& row, const partition_t& part, std::vector& weights, @@ -364,17 +364,17 @@ bhw_row_rewrite_t bhw_reduce_row( std::array integral{}; int64_t largest = 0; for (int j = 0; j < len; ++j) { - integral[j] = (int64_t)std::llround((double)coefficients[j] * scale) * direction; + integral[j] = std::llround((double)coefficients[j] * scale) * direction; if (integral[j] == 0) return rewrite; largest = std::max(largest, std::abs(integral[j])); } - // A row already at +/-1 has no magnitude to give back. The census puts most of the corpus here, - // so this test carries the screening cost. + // A row already at +/-1 has no magnitude to give back; rejecting it here is what keeps screening + // cheap on the rows that dominate a model. if (largest <= 1) return rewrite; norm_row_t norm_row; norm_row.k = len; - norm_row.rhs = (int64_t)std::llround((double)side * scale) * direction; + norm_row.rhs = std::llround((double)side * scale) * direction; std::array order{}; for (int j = 0; j < len; ++j) { order[j] = j; @@ -419,7 +419,7 @@ bhw_row_rewrite_t bhw_reduce_row( *std::max_element(result->weights.begin(), result->weights.end()) == result->weights[0], "N3 leaves the reduced weights non-increasing"); cuopt_assert(result->weights[0] < norm_row.coef[0] || - std::count(result->weights.begin(), result->weights.end(), (int64_t)0) > 0, + std::count(result->weights.begin(), result->weights.end(), 0) > 0, "an accepted rewrite must shrink the magnitude or drop a variable"); cuopt_assert(verify_equivalent(norm_row, result->weights.data(), result->bound), "BHW rewrite changed the 0/1 feasible set"); @@ -445,7 +445,59 @@ bhw_row_rewrite_t bhw_reduce_row( rewrite.accepted = true; return rewrite; } -static constexpr int BHW_INTERRUPT_CHECK_STRIDE = 256; + +namespace { + +// Coefficient-shrink figures behind the DEBUG line. Both the accumulation and the summary compile +// away below DEBUG, so a release build carries neither the per-row vector nor the selection. +struct bhw_stats_t { +#if (CUOPT_LOG_ACTIVE_LEVEL <= RAPIDS_LOGGER_LOG_LEVEL_DEBUG) + int64_t coefficients_reduced = 0; + int64_t coefficients_dropped = 0; + std::vector row_shrinks; + + void changed_coefficient(int64_t new_coefficient) + { + ++coefficients_reduced; + coefficients_dropped += new_coefficient == 0; + } + + void rewrote_row(int64_t max_coef_before, int64_t max_coef_after) + { + row_shrinks.push_back((double)max_coef_before / max_coef_after); + } + + void report() + { + if (coefficients_reduced == 0) return; + const size_t n_rows_rewritten = row_shrinks.size(); + cuopt_assert(n_rows_rewritten > 0, "a changed coefficient implies an accepted row"); + const double mean = + std::accumulate(row_shrinks.begin(), row_shrinks.end(), 0.0) / n_rows_rewritten; + // One row collapsing to magnitude 1 dominates the mean, so report the median next to it. + const auto middle = row_shrinks.begin() + n_rows_rewritten / 2; + std::nth_element(row_shrinks.begin(), middle, row_shrinks.end()); + double median = *middle; + if (n_rows_rewritten % 2 == 0) + median = (median + *std::max_element(row_shrinks.begin(), middle)) / 2.0; + + CUOPT_LOG_DEBUG( + "BHW reduced %ld coefficients (%ld dropped) in %zu rows, " + "max|a| shrank %.1fx mean, %.1fx median", + coefficients_reduced, + coefficients_dropped, + n_rows_rewritten, + mean, + median); + } +#else + void changed_coefficient(int64_t) {} + void rewrote_row(int64_t, int64_t) {} + void report() {} +#endif +}; + +} // namespace template papilo::PresolveStatus BHWCoeffReduce::execute(const papilo::Problem& problem, @@ -467,9 +519,7 @@ papilo::PresolveStatus BHWCoeffReduce::execute(const papilo::Problem& const int num_rows = constraint_matrix.getNRows(); papilo::PresolveStatus status = papilo::PresolveStatus::kUnchanged; - int64_t coefficients_reduced = 0; - int64_t coefficients_dropped = 0; - std::vector row_shrinks; + bhw_stats_t stats; // Every eligible row is screened, not only problemUpdate.getChangedActivities(): that worklist is // seeded in full once and afterwards fed only by activity changes, so a row whose side changes is @@ -514,10 +564,10 @@ papilo::PresolveStatus BHWCoeffReduce::execute(const papilo::Problem& cuopt_assert(rewrite.max_coef_after >= 1, "an accepted rewrite keeps at least one nonzero weight"); - row_shrinks.push_back((double)rewrite.max_coef_before / (double)rewrite.max_coef_after); + stats.rewrote_row(rewrite.max_coef_before, rewrite.max_coef_after); // Same shape as papilo's own sparsifier, SimplifyInequalities: lock the row, then the entries, - // then the side. Dropping an entry needs no column lock -- ProblemUpdate marks the column + // then the side. Dropping an entry needs no column lock: ProblemUpdate marks the column // modified and derives the resulting singleton rows and empty columns itself. The zero has to // be exact: SparseStorage::changeRowInplace compacts an entry out on newval == 0, not on // num.isZero. @@ -528,8 +578,7 @@ papilo::PresolveStatus BHWCoeffReduce::execute(const papilo::Problem& if ((f_t)rewrite.coefficients[j] == values[j]) continue; reductions.changeMatrixEntry(row, indices[j], (f_t)rewrite.coefficients[j]); ++emitted; - ++coefficients_reduced; - coefficients_dropped += rewrite.coefficients[j] == 0; + stats.changed_coefficient(rewrite.coefficients[j]); } if (direction == 1) { if ((f_t)rewrite.side != rhs_values[row]) { @@ -548,28 +597,7 @@ papilo::PresolveStatus BHWCoeffReduce::execute(const papilo::Problem& status = papilo::PresolveStatus::kReduced; } - if (coefficients_reduced > 0) { - const size_t n_rows_rewritten = row_shrinks.size(); - cuopt_assert(n_rows_rewritten > 0, "a changed coefficient implies an accepted row"); - const double mean = - std::accumulate(row_shrinks.begin(), row_shrinks.end(), 0.0) / (double)n_rows_rewritten; - // Mean alone is outlier-driven here: one row collapsing from a large coefficient to 1 outweighs - // hundreds of modest rewrites, so report the median next to it. - const auto middle = row_shrinks.begin() + n_rows_rewritten / 2; - std::nth_element(row_shrinks.begin(), middle, row_shrinks.end()); - double median = *middle; - if (n_rows_rewritten % 2 == 0) - median = (median + *std::max_element(row_shrinks.begin(), middle)) / 2.0; - - CUOPT_LOG_DEBUG( - "BHW reduced %ld coefficients (%ld dropped) in %zu rows, " - "max|a| shrank %.1fx mean, %.1fx median", - coefficients_reduced, - coefficients_dropped, - n_rows_rewritten, - mean, - median); - } + stats.report(); return status; } diff --git a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp index d62696f629..fee1d27334 100644 --- a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp +++ b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp @@ -26,12 +26,12 @@ namespace cuopt::mathematical_optimization::mip { -// Widest row we enumerate: building the point partition walks 2^BHW_MAX_LEN patterns. A census of -// the PaPILO-reduced MIPLIB corpus (6.2M all-binary one-sided rows) puts 98.0% of the exactly -// integralizable ones at nnz <= 12; raising this to 40 would add 2.0%. +// Widest row we enumerate: building the point partition walks 2^BHW_MAX_LEN patterns, so this caps +// the cost of screening one row. Wider rows are left alone; nearly every row that integerizes +// exactly is narrower than this. static constexpr int BHW_MAX_LEN = 12; // Largest max|w| the exhaustive search considers before falling back to the heuristic candidates. -// Accepted weights were at most 3 throughout the corpus study. +// Reductions needing a larger magnitude are rare enough not to pay for the extra search depth. static constexpr int64_t BHW_EXACT_MAX_WEIGHT = 6; // Largest per-row rational multiplier / denominator used to integerize a row (passed to // row_int_scale as its maxdnom/maxfinal caps). diff --git a/cpp/tests/mip/bhw_coeff_reduce_test.cpp b/cpp/tests/mip/bhw_coeff_reduce_test.cpp index 7dd6769b6d..20b8fdb0f6 100644 --- a/cpp/tests/mip/bhw_coeff_reduce_test.cpp +++ b/cpp/tests/mip/bhw_coeff_reduce_test.cpp @@ -60,16 +60,16 @@ bool same_feasible_set(const std::vector& coefficients, } // namespace // Bradley, Hammer and Wolsey (1974) open with this row and reduce it to 4,4,2,2,1,1,1,0 <= 5. That -// rewrite enlarges the LP relaxation -- it admits a fractional point of activity 91.75 against a -// right-hand side of 80 -- so the LP-strength check rejects it and no smaller equivalent form +// rewrite enlarges the LP relaxation (it admits a fractional point of activity 91.75 against a +// right-hand side of 80), so the LP-strength check rejects it and no smaller equivalent form // survives. TEST(bhw_coeff_reduce, rejects_a_rewrite_that_weakens_the_relaxation) { EXPECT_FALSE(reduce({65, 64, 41, 22, 13, 12, 8, 2}, 80).accepted); } -// The shape that motivated the pass: bnatt400 leaves rows whose coefficients are 9/10 and 1/3 -// against a right-hand side of 2/3. Integerizing and reducing lands them in int8 range. +// The shape that motivated the pass: a 9/10 coefficient against thirds, with a fractional +// right-hand side. Integerizing and reducing lands it in int8 range. TEST(bhw_coeff_reduce, rational_row_integerizes_and_reduces) { const std::vector row{0.9, 1.0 / 3, 1.0 / 3, 1.0 / 3}; From 0fe2abb258b16dca506fd528ed9c9cadbb13c635 Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Thu, 6 Aug 2026 03:28:49 -0700 Subject: [PATCH 18/78] Add a binary/int8 fast path to the CPU feasibility jump Instances whose variables are all binary and whose rows carry integer coefficients within int8 or int16 range run an integer engine instead of the general path. Feasibility is an exact compare against one row bound after a one-sided split, so there is no tolerance arithmetic and no compensated summation anywhere; a live per-variable score is patched through stored per-nnz contributions, and move selection is a global argmax over that score while the objective weight is zero. The hot kernels are vectorized with Google Highway, compiled once per SIMD target and dispatched at runtime. They live in a host-compiled .cpp because nvcc's frontend rejects Highway's x86 headers, so the seam passes only plain pointers and scalars. Choices settled by measurement rather than argument, on an EPYC 9554 (Zen 4, AVX-512) unless noted, with supportcase22 and bnatt400 at 16 climbers: - The row remainder is masked into the vector body on targets with mask registers and peeled into a scalar tail elsewhere. Masking cost 6.8% and 12.9% on AVX2, where the mask becomes a vector and scatter is emulated. - The score read-modify-write goes lane by lane on Zen 4 rather than through VPSCATTERDD, which is 89 uops there against ~19 on SPR-class parts: +5.8%. The assignment gather stays in hardware; doing that one by lane cost 8.2%, to store-to-load forwarding on the spilled index vector. - Rows dispatch to a 4-, 8- or native-width kernel by length, since a gather costs the same whether its lanes carry data or are masked off and rows are far shorter than a 512-bit vector. Worth +2.0% on supportcase22 and +3.9% on bnatt400. - The reverse-CSR row walk carries a software prefetch. Neutral here, kept because the walk is the one data-dependent access the hardware cannot follow. Also adds solve_CPUFJ, a benchmark-only harness that runs a portfolio of climbers on one instance, and an end-of-solve audit that recomputes the incumbent's row activities in int64 and its objective from the assignment alone, trusting nothing the incremental path maintained. Co-Authored-By: Claude Opus 5 --- .../linear_programming/cuopt/run_cpufj.cu | 207 +++ cpp/CMakeLists.txt | 69 + cpp/src/mip_heuristics/CMakeLists.txt | 2 + .../mip_heuristics/feasibility_jump/fj_cpu.cu | 11 + .../feasibility_jump/fj_cpu.cuh | 17 + .../feasibility_jump/fj_cpu_binary.cu | 1108 +++++++++++++++++ .../feasibility_jump/fj_cpu_binary.cuh | 169 +++ .../fj_cpu_binary_kernels.cpp | 449 +++++++ cpp/src/utilities/version_info.cpp | 21 + 9 files changed, 2053 insertions(+) create mode 100644 benchmarks/linear_programming/cuopt/run_cpufj.cu create mode 100644 cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu create mode 100644 cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh create mode 100644 cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp diff --git a/benchmarks/linear_programming/cuopt/run_cpufj.cu b/benchmarks/linear_programming/cuopt/run_cpufj.cu new file mode 100644 index 0000000000..6844d09347 --- /dev/null +++ b/benchmarks/linear_programming/cuopt/run_cpufj.cu @@ -0,0 +1,207 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +// Benchmark-only harness for the CPU feasibility-jump portfolio. Loads an instance, builds one +// climber per portfolio slot from a zero start clamped to variable bounds, and runs them in +// parallel on pinned cores for a fixed wall-clock budget. Reports per-climber crossing, objective +// and throughput. +// +// No presolve: pass an already-presolved instance. The climbers are built from problem_t, so the +// binary fast path is reachable when the instance qualifies. + +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using i_t = int; +using f_t = double; +namespace mip = cuopt::mathematical_optimization::mip; + +using clk = std::chrono::high_resolution_clock; +double since(clk::time_point t0) +{ + return std::chrono::duration_cast>(clk::now() - t0).count(); +} + +struct climber_result_t { + bool crossed{false}; + double t_first{-1.0}; + f_t best_objective{std::numeric_limits::infinity()}; + i_t iterations{0}; + double seconds{0.0}; +}; + +void pin_to_core(int core) +{ + cpu_set_t set; + CPU_ZERO(&set); + CPU_SET(core, &set); + pthread_setaffinity_np(pthread_self(), sizeof(set), &set); +} + +// The CPUs this process is actually permitted to run on. A cgroup mask can be non-contiguous, so +// indexing hardware_concurrency() directly would collide several climbers onto one core. +std::vector allowed_cpus() +{ + std::vector allowed; + cpu_set_t set; + CPU_ZERO(&set); + if (sched_getaffinity(0, sizeof(set), &set) == 0) { + for (int cpu = 0; cpu < CPU_SETSIZE; ++cpu) { + if (CPU_ISSET(cpu, &set)) allowed.push_back(cpu); + } + } + if (allowed.empty()) allowed.push_back(0); + return allowed; +} + +void run_climber(mip::fj_cpu_climber_t* climber, + f_t time_limit, + int core, + climber_result_t& result) +{ + pin_to_core(core); + const auto t0 = clk::now(); + + climber->improvement_callback = [&result, t0](f_t objective, const std::vector&, double) { + if (!result.crossed) { + result.crossed = true; + result.t_first = since(t0); + } + result.best_objective = objective; + }; + + mip::cpufj_solve(climber, time_limit); + + result.seconds = since(t0); + result.iterations = climber->iterations; +} + +} // namespace + +int main(int argc, char** argv) +{ + if (argc < 2) { + std::fprintf(stderr, "usage: %s [time_limit_s=60] [climbers=16] [seed=12345]\n", + argv[0]); + return 2; + } + const std::string path = argv[1]; + const f_t time_limit = argc > 2 ? std::atof(argv[2]) : 60.0; + const int n_climbers = argc > 3 ? std::atoi(argv[3]) : 16; + const unsigned base_seed = argc > 4 ? (unsigned)std::atoll(argv[4]) : 12345u; + + // Console sink so the engine's end-of-solve incumbent audit is visible, as solve_MIP does it. + cuopt::init_logger_t log_guard("", true); + + raft::handle_t handle; + + const auto mps_data_model = cuopt::mathematical_optimization::io::read_mps(path, false); + const auto op_problem = + cuopt::mathematical_optimization::mps_data_model_to_optimization_problem( + &handle, mps_data_model); + mip::problem_t problem(op_problem); + std::printf("instance: %s n_vars=%d n_cstrs=%d nnz=%d\n", + path.c_str(), + problem.n_variables, + problem.n_constraints, + problem.nnz); + + // Zero start, clamped into the variable bounds. Shared by every climber; diversity comes from + // the per-climber seed and sampling parameters below. + mip::solution_t solution(problem); + thrust::fill(handle.get_thrust_policy(), solution.assignment.begin(), solution.assignment.end(), f_t{0}); + mip::clamp_within_var_bounds(solution.assignment, &problem, &handle); + handle.sync_stream(); + + // Built serially: each climber host-copies the problem off the same stream. + std::vector> preemption_flags(n_climbers); + std::vector>> climbers(n_climbers); + for (int k = 0; k < n_climbers; ++k) { + preemption_flags[k].store(false); + mip::fj_settings_t settings; + settings.seed = (int)(base_seed + k); + climbers[k] = mip::init_fj_cpu_standalone(problem, solution, preemption_flags[k], settings); + + // Portfolio diversification, decorrelated from the value RNG. + std::mt19937 rng(base_seed + 7919u * k); + climbers[k]->mtm_viol_samples = std::uniform_int_distribution(15, 50)(rng); + climbers[k]->mtm_sat_samples = std::uniform_int_distribution(10, 30)(rng); + climbers[k]->nnz_samples = std::uniform_int_distribution(2000, 15000)(rng); + climbers[k]->perturb_interval = std::uniform_int_distribution(50, 500)(rng); + climbers[k]->log_prefix = "[climber " + std::to_string(k) + "] "; + } + + const std::vector cpus = allowed_cpus(); + std::printf("running %d climbers x %.0fs, base seed %u, %zu allowed CPUs (%d..%d)\n", + n_climbers, (double)time_limit, base_seed, cpus.size(), cpus.front(), cpus.back()); + + std::vector results(n_climbers); + std::vector threads; + threads.reserve(n_climbers); + const auto wall0 = clk::now(); + for (int k = 0; k < n_climbers; ++k) { + threads.emplace_back( + run_climber, climbers[k].get(), time_limit, cpus[k % cpus.size()], std::ref(results[k])); + } + for (auto& t : threads) { + t.join(); + } + const double wall = since(wall0); + + int crossed = 0; + double sum_iters = 0; + f_t best_overall = std::numeric_limits::infinity(); + std::printf("\n climber | crossed | t_first(s) | obj | iters | iters/s\n"); + std::printf("---------+---------+------------+--------------+----------+---------\n"); + for (int k = 0; k < n_climbers; ++k) { + const auto& r = results[k]; + sum_iters += r.iterations; + if (r.crossed) { + ++crossed; + best_overall = std::min(best_overall, r.best_objective); + } + std::printf(" %7d | %7s | %10s | %12.6g | %8d | %8.0f\n", + k, + r.crossed ? "YES" : "no", + r.crossed ? std::to_string(r.t_first).c_str() : "-", + r.crossed ? (double)r.best_objective : 0.0, + r.iterations, + r.seconds > 0 ? r.iterations / r.seconds : 0.0); + } + std::printf("\nSUMMARY: %d/%d crossed (%.0f%%) wall=%.1fs total_iters=%.0f agg_iters/s=%.0f\n", + crossed, + n_climbers, + 100.0 * crossed / n_climbers, + wall, + sum_iters, + wall > 0 ? sum_iters / wall : 0.0); + if (crossed > 0) { std::printf("BEST OBJECTIVE: %.10g\n", (double)best_overall); } + return 0; +} diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index b375cc4c56..d95f48f58b 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -308,6 +308,31 @@ FetchContent_MakeAvailable(pslp) set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_SAVED}) +# Highway - portable SIMD with runtime dispatch, used by the binary CPU FJ fast-path kernels +# https://github.com/google/highway +# contrib carries vqsort/image/math, none of which we use, and each would be compiled once per +# SIMD target. +FetchContent_Declare( + highway + GIT_REPOSITORY "https://github.com/google/highway.git" + GIT_TAG "1.4.0" + GIT_PROGRESS TRUE + EXCLUDE_FROM_ALL + SYSTEM +) + +set(HWY_ENABLE_CONTRIB OFF CACHE BOOL "" FORCE) +set(HWY_ENABLE_EXAMPLES OFF CACHE BOOL "" FORCE) +set(HWY_ENABLE_TESTS OFF CACHE BOOL "" FORCE) +set(HWY_ENABLE_INSTALL OFF CACHE BOOL "" FORCE) + +# Build Highway as static to embed in cuopt (mirrors PSLP above) +set(BUILD_SHARED_LIBS_SAVED ${BUILD_SHARED_LIBS}) +set(BUILD_SHARED_LIBS OFF) +FetchContent_MakeAvailable(highway) +set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_SAVED}) + + # dejavu - header-only graph automorphism library for MIP symmetry detection # https://github.com/markusa4/dejavu (header-only, skip its CMakeLists.txt) FetchContent_Declare( @@ -666,6 +691,7 @@ target_include_directories(cuopt_objs PRIVATE target_include_directories(cuopt_objs SYSTEM PRIVATE "${pslp_SOURCE_DIR}/include" "${dejavu_SOURCE_DIR}" + "${highway_SOURCE_DIR}" ) target_include_directories(cuopt_objs @@ -691,6 +717,11 @@ target_include_directories(cuopt_objs target_link_libraries(cuopt_objs PRIVATE $) add_dependencies(cuopt_objs PSLP) +# Highway, linked by file for the same reason. Static and fully embedded into libcuopt.so; it is +# never installed (HWY_ENABLE_INSTALL OFF) and consumers of cuopt::cuopt never reference it. +target_link_libraries(cuopt_objs PRIVATE $) +add_dependencies(cuopt_objs hwy) + # Link KaMinPar by file to avoid export dependency tracking (mirrors PSLP above). # KaMinPar is a from-source static library fully embedded into libcuopt.so; it is never # installed (INSTALL_KAMINPAR OFF) and consumers of cuopt::cuopt never use it, so it must @@ -813,6 +844,8 @@ if (BUILD_TESTS) ) target_link_libraries(cuopt_static PRIVATE $) add_dependencies(cuopt_static PSLP) + target_link_libraries(cuopt_static PRIVATE $) + add_dependencies(cuopt_static hwy) target_link_libraries(cuopt_static PRIVATE $) if (TARGET KaMinPar) add_dependencies(cuopt_static KaMinPar) @@ -857,6 +890,8 @@ target_link_libraries(cuopt ) target_link_libraries(cuopt PRIVATE $) add_dependencies(cuopt PSLP) +target_link_libraries(cuopt PRIVATE $) +add_dependencies(cuopt hwy) target_link_libraries(cuopt PRIVATE $) if (TARGET KaMinPar) add_dependencies(cuopt KaMinPar) @@ -1065,6 +1100,40 @@ if (BUILD_MIP_BENCHMARKS AND NOT BUILD_LP_ONLY) "${CMAKE_CURRENT_SOURCE_DIR}/src" ) + # CPU FJ portfolio benchmark. A .cu because it drives problem_t/solution_t directly and + # clamp_within_var_bounds instantiates a device lambda in this TU. + add_executable(solve_CPUFJ ../benchmarks/linear_programming/cuopt/run_cpufj.cu) + + set_target_properties(solve_CPUFJ PROPERTIES CXX_SCAN_FOR_MODULES OFF) + + # -fopenmp for the CUDA TU as well: the internal headers this pulls in (omp_helpers.hpp, + # omp_atomic_t) are OMP-dependent, and OpenMP::OpenMP_CXX only covers CXX. + target_compile_options(solve_CPUFJ + PRIVATE "$<$:${CUOPT_CXX_FLAGS}>" + "$<$:${CUOPT_CUDA_FLAGS}>" + "$<$:-fopenmp>" + ) + + target_link_libraries(solve_CPUFJ + PUBLIC + cuopt + OpenMP::OpenMP_CXX + OpenMP::OpenMP_CUDA + ) + + target_include_directories(solve_CPUFJ + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + "${papilo_SOURCE_DIR}/src" + "${papilo_BINARY_DIR}" + ) + + # Reached transitively: fj_cpu.cuh pulls branch_and_bound/symmetry.hpp, which needs dejavu. + target_include_directories(solve_CPUFJ SYSTEM PRIVATE + "${pslp_SOURCE_DIR}/include" + "${dejavu_SOURCE_DIR}" + ) + endif () option(BUILD_LP_BENCHMARKS "Build LP benchmarks" OFF) diff --git a/cpp/src/mip_heuristics/CMakeLists.txt b/cpp/src/mip_heuristics/CMakeLists.txt index f54619cfe3..a35cdd7e4f 100644 --- a/cpp/src/mip_heuristics/CMakeLists.txt +++ b/cpp/src/mip_heuristics/CMakeLists.txt @@ -45,6 +45,8 @@ set(MIP_NON_LP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/feasibility_jump.cu ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/feasibility_jump_kernels.cu ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/fj_cpu.cu + ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/fj_cpu_binary.cu + ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/fj_cpu_binary_kernels.cpp ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/early_cpufj.cu ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/early_gpufj.cu) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index b789159953..e1b7082b0d 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -13,6 +13,7 @@ #include "feasibility_jump.cuh" #include "feasibility_jump_impl_common.cuh" #include "fj_cpu.cuh" +#include "fj_cpu_binary.cuh" #include "fj_cpu_worker.cuh" #include @@ -1700,6 +1701,11 @@ void finalize_fj_cpu_host_initialization( // Precompute static problem features for regression model precompute_problem_features(fj_cpu); + + // Binary fast path. Depends only on the host problem mirrors and the incoming weights, both + // populated above; engine state is initialized later, at solve entry. Climbers built from a + // host LP reach here too and are declined by the predicate at their first slack column. + try_build_binary_fastpath(fj_cpu); } template @@ -1893,6 +1899,11 @@ std::unique_ptr> fj_t::create_cpu_climber( template void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double work_unit_limit) { + if (fj_cpu->binary_fast) { + fj_cpu->binary_fast->solve(*fj_cpu, in_time_limit, work_unit_limit); + return; + } + i_t local_mins = 0; auto loop_start = std::chrono::high_resolution_clock::now(); auto time_limit = std::chrono::milliseconds(static_cast(std::floor(in_time_limit * 1000.0))); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 411b4083f7..ce1d010151 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,17 @@ namespace cuopt::mathematical_optimization::mip { +// Binary fast-path state. Defined in fj_cpu_binary.cuh, which only fj_cpu.cu includes, so this +// header stays independent of the fast path. The deleter is defined out of line for the same +// reason, mirroring fj_cpu_worker_t::fj_cpu_deleter_t. +template +struct fj_binary_state_t; + +template +struct fj_binary_state_deleter_t { + void operator()(fj_binary_state_t* ptr) const; +}; + template class probing_cache_t; @@ -215,6 +227,11 @@ struct fj_cpu_climber_t { instrumentation_aggregator_t memory_aggregator; // TODO atomic ref? c++20 std::atomic& preemption_flag; + + // Populated by try_build_binary_fastpath when the instance is all-binary with integer + // coefficients in int8/int16 range. Empty on every other instance, in which case cpufj_solve + // runs the general loop below. + std::unique_ptr, fj_binary_state_deleter_t> binary_fast; }; template diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu new file mode 100644 index 0000000000..5c5a28d9f5 --- /dev/null +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -0,0 +1,1108 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "fj_cpu_binary.cuh" + +#include "feasibility_jump.cuh" +#include "fj_cpu.cuh" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +const char* fj_binary_reject_name(fj_binary_reject_t reason) +{ + switch (reason) { + case fj_binary_reject_t::none: return "none"; + case fj_binary_reject_t::empty_problem: return "empty problem"; + case fj_binary_reject_t::non_binary_var: return "non-binary variable"; + case fj_binary_reject_t::fractional_coefficient: return "fractional coefficient"; + case fj_binary_reject_t::coefficient_out_of_range: return "coefficient wider than int16"; + case fj_binary_reject_t::fractional_row_bound: return "fractional row bound"; + case fj_binary_reject_t::row_bound_out_of_range: return "row bound outside int32"; + case fj_binary_reject_t::lhs_headroom: return "row sum|coef| exceeds int32 headroom"; + case fj_binary_reject_t::narrow_check_failed: return "narrowing check failed"; + } + return "unknown"; +} + + +// Work-unit proxy: bytes attributed per nnz touched. Stands in for the byte counters the general +// path reads off its instrumented vectors. Calibrated by the owner. +constexpr double fj_bin_bytes_per_nnz = 16.0; + +// Tabu for binary variables. A binary variable's only move is a flip, so the direction is a +// function of the current assignment and the general four-array scheme collapses to two. +// flip_until is biased by a rolling base so it fits uint16. +struct fj_bin_tabu_t { + static constexpr int32_t window = 65535 - 64; + + std::vector flip_until; + std::vector last_flip; + int32_t base{0}; + + + void resize(int32_t n) + { + flip_until.assign(n, 0); + last_flip.assign(n, 0); + base = 0; + } + + void clear(int32_t iter) + { + std::fill(flip_until.begin(), flip_until.end(), (uint16_t)0); + std::fill(last_flip.begin(), last_flip.end(), 0); + base = iter; + } + + void on_flip(int32_t v, int32_t iter, int32_t tenure) + { + flip_until[v] = (uint16_t)(iter + tenure - base); + last_flip[v] = iter; + } + + + bool blocked(int32_t v, int32_t iter, bool localmin) const + { + return localmin ? (iter == last_flip[v] + 1) : ((uint16_t)(iter - base) < flip_until[v]); + } + + // Rebase before iter - base can overflow the uint16 window. Expired entries saturate to 0, + // which reads as not-tabu. + void maybe_advance(int32_t iter) + { + if ((int64_t)iter - base <= window) return; + const uint16_t shift = (uint16_t)(iter - base); + for (uint16_t& fu : flip_until) fu = (fu > shift) ? (uint16_t)(fu - shift) : (uint16_t)0; + base = iter; + } +}; + +// One row of the narrowed problem. bound/sign encode the single finite bound the split leaves: +// sign +1 for lhs <= bound, -1 for lhs >= bound. cmax is max|coef| over the row, which bounds how +// far a single flip can move the slack. +template +struct fj_bin_row_t { + int32_t lhs; + int32_t weight; + int32_t bound; + coef_t cmax; + int8_t sign; +}; + +// Narrowed problem: one-sided rows, integer coefficients, CSR plus its transpose. +template +struct fj_bin_problem_t { + int32_t n_variables{0}; + int32_t n_constraints{0}; + int32_t nnz{0}; + + std::vector offsets; + std::vector variables; + std::vector coefficients; + + std::vector reverse_offsets; + std::vector reverse_constraints; + std::vector reverse_coefficients; + std::vector reverse_to_csr; + + std::vector bound; + std::vector sign; + std::vector cmax; + std::vector initial_weight; + + std::vector objective; + std::vector objective_vars; +}; + +// Result of the width-independent eligibility scan. +struct fj_bin_scan_t { + fj_binary_reject_t reject{fj_binary_reject_t::none}; + int coefficient_bits{0}; + int32_t n_split_constraints{0}; + int32_t bad_row{-1}; + int32_t bad_var{-1}; +}; + +// DDFW and restart have no general-path equivalent, so their defaults live here until there is a +// reason to promote them alongside the other FJ knobs. +constexpr int32_t fj_bin_ddfw_init = 10; // initial weight, also the donation floor +constexpr int32_t fj_bin_ddfw_transfer = 1; +constexpr int32_t fj_bin_ddfw_donor_samples = 1; +constexpr int32_t fj_bin_restart_period = 5000000; + +// `[study]` Prefetch distance for the reverse-CSR row walk, in rows. Each move visits the rows of +// one variable through reverse_constraints, whose entries are effectively random indices into a row +// array far larger than L2, and the sequence is data-dependent so no hardware prefetcher can follow +// it. reverse_constraints is padded by this much so the lookahead needs no bounds test. +constexpr int32_t fj_bin_pf_dist = 8; + +// Limits of the packed score. Breaching either corrupts the ordering, so compute_saturation +// reports the observed peaks against them at end of solve. +constexpr int32_t fj_bin_base_limit = 1 << 16; +constexpr int32_t fj_bin_bonus_limit = 1 << 14; + +static inline bool fj_bin_is_integral(double v, double tol) { return std::fabs(v - std::round(v)) <= tol; } + +static inline bool fj_bin_in_int32(double v) +{ + return v >= (double)INT32_MIN && v <= (double)INT32_MAX; +} + +// Width-independent eligibility scan over the climber's host mirrors. Mutates nothing. +template +static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) +{ + fj_bin_scan_t out; + const int32_t n = c.view.pb.n_variables; + const int32_t m = c.view.pb.n_constraints; + if (n <= 0 || m <= 0) { + out.reject = fj_binary_reject_t::empty_problem; + return out; + } + + const double tol = c.view.pb.tolerances.integrality_tolerance; + const auto& var_bounds = c.h_var_bounds; + const auto& var_types = c.h_var_types; + + for (int32_t v = 0; v < n; ++v) { + auto bounds = var_bounds[v]; + if (var_types[v] != var_t::INTEGER || std::fabs(get_lower(bounds)) > tol || + std::fabs(get_upper(bounds) - 1.0) > tol) { + out.reject = fj_binary_reject_t::non_binary_var; + out.bad_var = v; + return out; + } + } + + const auto& offsets = c.h_offsets; + const auto& coeffs = c.h_coefficients; + const auto& cstr_lb = c.h_cstr_lb; + const auto& cstr_ub = c.h_cstr_ub; + + double max_abs_coefficient = 0; + for (int32_t r = 0; r < m; ++r) { + double row_abs_sum = 0; + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { + const double a = coeffs[k]; + if (!fj_bin_is_integral(a, tol)) { + out.reject = fj_binary_reject_t::fractional_coefficient; + out.bad_row = r; + return out; + } + const double abs_a = std::fabs(std::round(a)); + row_abs_sum += abs_a; + if (abs_a > max_abs_coefficient) max_abs_coefficient = abs_a; + } + + // A binary assignment can drive lhs to sum|coef|; keep that inside the int32 accumulator with + // room to spare. The int8-only reference engine never needed this bound. + if (row_abs_sum > (double)(INT32_MAX / 2)) { + out.reject = fj_binary_reject_t::lhs_headroom; + out.bad_row = r; + return out; + } + + const double lb = cstr_lb[r]; + const double ub = cstr_ub[r]; + const bool lb_fin = std::isfinite(lb); + const bool ub_fin = std::isfinite(ub); + const double sides[2] = {lb, ub}; + const bool finite[2] = {lb_fin, ub_fin}; + for (int s = 0; s < 2; ++s) { + if (!finite[s]) continue; + if (!fj_bin_is_integral(sides[s], tol)) { + out.reject = fj_binary_reject_t::fractional_row_bound; + out.bad_row = r; + return out; + } + if (!fj_bin_in_int32(std::round(sides[s]))) { + out.reject = fj_binary_reject_t::row_bound_out_of_range; + out.bad_row = r; + return out; + } + } + // Free rows are dropped: trivially satisfied, contributing nothing to the search. + out.n_split_constraints += (int32_t)lb_fin + (int32_t)ub_fin; + } + + if (out.n_split_constraints <= 0) { + out.reject = fj_binary_reject_t::empty_problem; + return out; + } + + if (max_abs_coefficient <= 127.0) { + out.coefficient_bits = 8; + } else if (max_abs_coefficient <= 32767.0) { + out.coefficient_bits = 16; + } else { + out.reject = fj_binary_reject_t::coefficient_out_of_range; + } + return out; +} + +// Build the narrowed, one-sided problem. Called only after fj_bin_scan cleared the instance, so a +// failing check here is a self-consistency bug and refuses the fast path rather than truncating. +template +static bool fj_bin_narrow(const fj_cpu_climber_t& c, + int32_t n_split, + fj_bin_problem_t& pb) +{ + const int32_t n = c.view.pb.n_variables; + const int32_t m = c.view.pb.n_constraints; + const double tol = c.view.pb.tolerances.integrality_tolerance; + + const auto& offsets = c.h_offsets; + const auto& variables = c.h_variables; + const auto& coeffs = c.h_coefficients; + const auto& cstr_lb = c.h_cstr_lb; + const auto& cstr_ub = c.h_cstr_ub; + const auto& left_w = c.h_cstr_left_weights; + const auto& right_w = c.h_cstr_right_weights; + const auto& obj = c.h_obj_coeffs; + + pb.n_variables = n; + pb.n_constraints = n_split; + pb.offsets.assign(1, 0); + pb.offsets.reserve(n_split + 1); + pb.bound.reserve(n_split); + pb.sign.reserve(n_split); + pb.cmax.reserve(n_split); + pb.initial_weight.reserve(n_split); + + std::vector incoming_weight; + incoming_weight.reserve(n_split); + + // Each split row inherits the weight of the side it came from: left is the lower-bound side, + // right the upper. + auto emit = [&](int32_t r, double side_bound, int8_t sign, double weight) -> bool { + coef_t row_cmax = 1; + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { + const double a = coeffs[k]; + const long ai = std::lround(a); + if (!fj_bin_is_integral(a, tol) || ai < std::numeric_limits::min() || + ai > std::numeric_limits::max()) { + return false; + } + pb.variables.push_back(variables[k]); + pb.coefficients.push_back((coef_t)ai); + const coef_t abs_a = (coef_t)std::labs(ai); + if (abs_a > row_cmax) row_cmax = abs_a; + } + const long b = std::lround(side_bound); + if (!fj_bin_in_int32((double)b)) return false; + pb.offsets.push_back((int32_t)pb.variables.size()); + pb.bound.push_back((int32_t)b); + pb.sign.push_back(sign); + pb.cmax.push_back(row_cmax); + incoming_weight.push_back(weight); + return true; + }; + + for (int32_t r = 0; r < m; ++r) { + const double lb = cstr_lb[r]; + const double ub = cstr_ub[r]; + if (std::isfinite(lb) && !emit(r, lb, (int8_t)-1, left_w[r])) return false; + if (std::isfinite(ub) && !emit(r, ub, (int8_t)1, right_w[r])) return false; + } + if ((int32_t)pb.bound.size() != n_split) return false; + pb.nnz = (int32_t)pb.variables.size(); + + // One vector of padding past nnz, so the row kernel can load and store whole vectors at the last + // row without running off the end and can therefore mask its remainder rather than peeling it + // into a scalar tail. The padding is never read as data: every lane past a row's end is excluded + // from the gather, the scatter and the store by the row-length mask. + const int32_t pad = fj_bin_simd_padding(); + pb.variables.resize(pb.nnz + pad, 0); + pb.coefficients.resize(pb.nnz + pad, (coef_t)0); + + // Scale the incoming weights into the DDFW band by one global factor, so relative structure + // survives while every row clears the donation floor. Capped so the largest scaled weight stays + // clear of packed-score saturation; where the cap binds, the smallest rows sit below the floor. + // TODO: bound the scaled weights by derivation instead of leaving them open. The packed score + // holds while a variable's aggregate base stays under 2^16, and that aggregate is bounded by the + // sum of weights over the rows the variable appears in, so 2^16 / max_var_degree gives a per-row + // bound computable here from the transpose. Left uncapped for now, matching the reference + // engine, which shipped with its weight cap disabled and relied on the end-of-solve saturation + // report to say whether a bound was needed. + double w_min = std::numeric_limits::infinity(); + for (double w : incoming_weight) { + if (w > 0 && w < w_min) w_min = w; + } + double scale = 1.0; + if (std::isfinite(w_min) && w_min > 0) { + scale = (double)fj_bin_ddfw_init / w_min; + if (scale < 1.0) scale = 1.0; + } + for (double w : incoming_weight) { + int32_t scaled = w > 0 ? (int32_t)std::lround(w * scale) : fj_bin_ddfw_init; + if (scaled < 1) scaled = 1; + pb.initial_weight.push_back(scaled); + } + + // Transpose, plus the reverse-nnz to CSR-nnz map the apply path uses to store the flipped + // variable's own score delta. + pb.reverse_offsets.assign(n + 1, 0); + for (int32_t k = 0; k < pb.nnz; ++k) pb.reverse_offsets[pb.variables[k] + 1]++; + for (int32_t v = 0; v < n; ++v) pb.reverse_offsets[v + 1] += pb.reverse_offsets[v]; + pb.reverse_constraints.resize(pb.nnz); + pb.reverse_coefficients.resize(pb.nnz); + pb.reverse_to_csr.resize(pb.nnz); + { + std::vector cursor(pb.reverse_offsets.begin(), pb.reverse_offsets.begin() + n); + for (int32_t r = 0; r < n_split; ++r) { + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { + const int32_t slot = cursor[pb.variables[k]]++; + pb.reverse_constraints[slot] = r; + pb.reverse_coefficients[slot] = pb.coefficients[k]; + pb.reverse_to_csr[slot] = k; + } + } + } + // Lookahead room for the row-walk prefetch. Reads land on row 0, which is prefetched harmlessly. + pb.reverse_constraints.resize(pb.nnz + fj_bin_pf_dist, 0); + + pb.objective.resize(n); + for (int32_t v = 0; v < n; ++v) { + pb.objective[v] = obj[v]; + if (pb.objective[v] != 0.0) pb.objective_vars.push_back(v); + } + return true; +} + + +// The integer engine. Feasibility is an exact compare against one bound per row, so there is no +// tolerance arithmetic and no compensated summation anywhere below. +template +struct fj_bin_engine_t : fj_binary_state_t { + fj_bin_problem_t pb; + std::vector> rows; + + std::vector assign; + std::vector best_assign; + std::vector seed_assign; // restart target + std::vector assign_i32; // gather mirror for the SIMD patch (Batch B) + + std::vector var_score; // live feasibility score of flipping each variable + std::vector nnz_score_delta; // per CSR nnz: last score delta of variables[k] in its row + + fj_bin_tabu_t tabu; + + std::vector is_violated; + std::vector violated_list; + std::vector vpos; + std::vector var_bitmap; + + // One generator advanced across the whole search, rather than one re-seeded per call site per + // iteration. Re-seeding from `seed + iters` gave every call site in an iteration the identical + // stream, and a 624-word Mersenne state was being built and discarded on every move selection. + raft::random::PCGenerator rng{0, 0, 0}; + std::vector sample_buf; // move-selection row sample, reused to keep the loop allocation-free + + int32_t objective_weight{0}; + double incumbent_objective{0}; + double best_objective{std::numeric_limits::infinity()}; + int32_t max_weight{1}; + bool feasible_found{false}; + + int32_t iters{0}; + int32_t last_feasible_entrance_iter{0}; + int32_t last_restart_iter{0}; + int64_t nnz_touched{0}; + + // Denominator for the ops-per-nnz roofline: nonzeros the row kernel actually processes, and the + // rows walked to find them. Unlike nnz_touched these are not mixed with the full-matrix rebuilds. + int64_t nnz_patched{0}; + int64_t rows_walked{0}; + + // Tile width for the argmax sweep. Governs how often the running maximum is raised, which is + // what bounds the index re-scan, so it is about the shape of the sweep and not cache capacity. + int32_t argmax_tile{256}; + + // Rows at or below these lengths go to the 4- and 8-lane patch kernels; 0 disables that width. + int32_t narrow4_max{0}; + int32_t narrow8_max{0}; + + // Settings read at solve entry, where the climber carries populated values. + int32_t seed{0}; + int32_t tabu_tenure_min{3}; + int32_t tabu_tenure_max{13}; + int32_t perturb_interval{100}; + int32_t mtm_viol_samples{25}; + int32_t mtm_sat_samples{15}; + double breakthrough_margin{1e-4}; + + int32_t max_aggregate_base{0}; + int32_t max_aggregate_bonus{0}; + + int coefficient_bits() const override { return 8 * (int)sizeof(coef_t); } + int n_split_constraints() const override { return pb.n_constraints; } + i_t iterations() const override { return (i_t)iters; } + + void saturation(int& base_peak, int& bonus_peak) const override + { + base_peak = max_aggregate_base; + bonus_peak = max_aggregate_bonus; + } + + // Largest per-variable aggregate base and bonus under the final weights and assignment, in raw + // int32. The packed representation is only order-preserving while these stay inside their + // limits, and weights grow without a cap, so this is the reading that says whether the packing + // survived the run. + // Independent audit of the incumbent at end of solve. Recomputes every row's lhs and the objective + // from best_assign alone, trusting nothing the incremental path maintained: not the live lhs, not + // violated_list, not the running incumbent_objective. Accumulates in int64 so an int32 lhs + // overflow the eligibility scan was supposed to preclude would show up here rather than wrap + // silently. Runs once per solve, so its cost is not on any hot path. + void verify_incumbent(fj_cpu_climber_t& climber) const + { + if (!feasible_found) return; + + int32_t n_violated = 0; + int64_t worst = 0; + bool lhs_overflow = false; + for (int32_t r = 0; r < pb.n_constraints; ++r) { + int64_t lhs = 0; + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { + lhs += (int64_t)pb.coefficients[k] * (int64_t)best_assign[pb.variables[k]]; + } + if (lhs < INT32_MIN || lhs > INT32_MAX) lhs_overflow = true; + const int64_t slack = (int64_t)pb.sign[r] * ((int64_t)pb.bound[r] - lhs); + if (slack < 0) { + ++n_violated; + if (-slack > worst) worst = -slack; + } + } + + double objective = 0; + for (int32_t v = 0; v < pb.n_variables; ++v) objective += pb.objective[v] * (double)best_assign[v]; + const double drift = std::fabs(objective - best_objective); + + if (n_violated != 0 || lhs_overflow || drift > 1e-6) { + CUOPT_LOG_ERROR( + "%sCPUFJ[bin%d] incumbent audit FAILED: %d violated rows (worst %lld), lhs overflow %d, " + "objective recomputed %.17g vs tracked %.17g (drift %g)", + climber.log_prefix.c_str(), + coefficient_bits(), + n_violated, + (long long)worst, + (int)lhs_overflow, + objective, + best_objective, + drift); + } else { + CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] incumbent audit ok: feasible, objective %.17g (drift %g)", + climber.log_prefix.c_str(), + coefficient_bits(), + objective, + drift); + } + } + + void compute_saturation() + { + int32_t peak_base = 0, peak_bonus = 0; + for (int32_t v = 0; v < pb.n_variables; ++v) { + const int8_t flip = (int8_t)(1 - 2 * assign[v]); + int32_t agg_base = 0, agg_bonus = 0; + for (int32_t i = pb.reverse_offsets[v]; i < pb.reverse_offsets[v + 1]; ++i) { + const fj_bin_row_t& h = rows[pb.reverse_constraints[i]]; + const int32_t s = h.sign; + const int32_t os = s * (h.bound - h.lhs); + const int32_t ns = os - s * ((int32_t)pb.reverse_coefficients[i] * flip); + int32_t base = 0, bonus = 0; + fj_bin_score_delta_parts(os, ns, h.weight, base, bonus); + agg_base += base; + agg_bonus += bonus; + } + const int32_t abs_base = agg_base < 0 ? -agg_base : agg_base; + const int32_t abs_bonus = agg_bonus < 0 ? -agg_bonus : agg_bonus; + if (abs_base > peak_base) peak_base = abs_base; + if (abs_bonus > peak_bonus) peak_bonus = abs_bonus; + } + max_aggregate_base = peak_base; + max_aggregate_bonus = peak_bonus; + } + + void set_violated(int32_t r) + { + if (!is_violated[r]) { + is_violated[r] = 1; + vpos[r] = (int32_t)violated_list.size(); + violated_list.push_back(r); + } + } + + void set_satisfied(int32_t r) + { + if (is_violated[r]) { + is_violated[r] = 0; + const int32_t p = vpos[r]; + const int32_t last = violated_list.back(); + violated_list[p] = last; + vpos[last] = p; + violated_list.pop_back(); + vpos[r] = -1; + } + } + + // Branchless score delta of flipping a variable, as seen by one row. base is the weighted change + // in satisfaction; bonus is the weighted change in strict slack. When both states are violated + // the improving direction earns half weight, matching excess_improvement_weight of 1/2. + int32_t score_delta(const fj_bin_row_t& h, int32_t lhs, int8_t delta, coef_t k) const + { + const int32_t s = h.sign; + const int32_t os = s * (h.bound - lhs); + const int32_t ns = os - s * ((int32_t)k * delta); + return fj_bin_packed_score_delta(os, ns, h.weight); + } + + void rebuild_scores() + { + std::fill(var_score.begin(), var_score.end(), 0); + for (int32_t r = 0; r < pb.n_constraints; ++r) { + const fj_bin_row_t& h = rows[r]; + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { + const int32_t v = pb.variables[k]; + const int32_t p = score_delta(h, h.lhs, (int8_t)(1 - 2 * assign[v]), pb.coefficients[k]); + nnz_score_delta[k] = p; + var_score[v] += p; + } + } + nnz_touched += pb.nnz; + } + + void recompute_lhs() + { + violated_list.clear(); + std::fill(is_violated.begin(), is_violated.end(), (uint8_t)0); + for (int32_t r = 0; r < pb.n_constraints; ++r) { + int32_t lhs = 0; + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) + lhs += (int32_t)pb.coefficients[k] * assign[pb.variables[k]]; + rows[r].lhs = lhs; + if (rows[r].sign * (rows[r].bound - lhs) < 0) set_violated(r); + } + incumbent_objective = 0; + for (int32_t v = 0; v < pb.n_variables; ++v) incumbent_objective += pb.objective[v] * assign[v]; + nnz_touched += pb.nnz; + rebuild_scores(); + } + + int32_t objective_terms(int32_t v, int8_t delta) const + { + const double obj_diff = pb.objective[v] * delta; + const int32_t base = obj_diff < 0 ? objective_weight : (obj_diff > 0 ? -objective_weight : 0); + int32_t bonus = 0; + const bool old_better = incumbent_objective < best_objective; + const bool new_better = incumbent_objective + obj_diff < best_objective; + if (!old_better && new_better) { + bonus += objective_weight; + } else if (old_better && !new_better) { + bonus -= objective_weight; + } + return base * fj_bin_score_k + bonus; + } + + int32_t full_score(int32_t v, int8_t delta) const + { + if (objective_weight == 0) return var_score[v]; + return var_score[v] + objective_terms(v, delta); + } + + bool tabu_blocked(int32_t v, bool localmin) const { return tabu.blocked(v, iters, localmin); } + + void apply_move(int32_t var, int8_t delta, fj_cpu_climber_t& climber) + { + const int8_t new_val = (int8_t)(assign[var] + delta); + const int8_t new_flip = (int8_t)(1 - 2 * new_val); + const int32_t ob = pb.reverse_offsets[var], oe = pb.reverse_offsets[var + 1]; + const int32_t prev_violated = (int32_t)violated_list.size(); + int32_t own_score = 0; + + for (int32_t ii = ob; ii < oe; ++ii) { + // Write hint: the row's lhs is updated at the end of every iteration, so the line is wanted + // exclusive. The padding on reverse_constraints makes the lookahead unconditional. + __builtin_prefetch(&rows[pb.reverse_constraints[ii + fj_bin_pf_dist]], 1, 3); + + const int32_t r = pb.reverse_constraints[ii]; + fj_bin_row_t& h = rows[r]; + const coef_t kv = pb.reverse_coefficients[ii]; + const int32_t old_lhs = h.lhs; + const int32_t new_lhs = old_lhs + (int32_t)kv * delta; + const int32_t s = h.sign; + const int32_t old_slack = s * (h.bound - old_lhs); + const int32_t new_slack = s * (h.bound - new_lhs); + + if (new_slack < 0 && old_slack >= 0) { + set_violated(r); + } else if (new_slack >= 0 && old_slack < 0) { + set_satisfied(r); + } + + // A row that stays clear of its boundary by more than max|coef| on both sides cannot change + // any variable's satisfaction flags, so its patch is skipped entirely. + const int32_t margin = h.cmax; + const bool deep_sat = old_slack > margin && new_slack > margin; + const bool deep_viol = old_slack < -margin && new_slack < -margin; + if (!(deep_sat || deep_viol)) { + const int32_t kb = pb.offsets[r], ke = pb.offsets[r + 1]; + // The offsets are already loaded for the call, so the width choice is a compare rather + // than a stored per-row flag. + // TODO: check that this may not cause AVX512 powerdown overheads if the AVX2 row/AVX512 row ratio is unbalanced + fj_bin_patch_row(fj_bin_patch_width_for(ke - kb, narrow4_max, narrow8_max), + pb.variables.data(), + pb.coefficients.data(), + kb, + ke, + var_score.data(), + nnz_score_delta.data(), + assign_i32.data(), + s, + h.weight, + new_slack, + var); + nnz_touched += ke - kb; + nnz_patched += ke - kb; + } + + // The flipped variable's own score delta is provably zero when the row is deeply satisfied + // both ways, and already stored as zero there. + if (!deep_sat) { + const int32_t pv = score_delta(h, new_lhs, new_flip, kv); + own_score += pv; + nnz_score_delta[pb.reverse_to_csr[ii]] = pv; + } + h.lhs = new_lhs; + } + nnz_touched += oe - ob; + rows_walked += oe - ob; + + if (prev_violated > 0 && violated_list.empty()) last_feasible_entrance_iter = iters; + + assign[var] = new_val; + assign_i32[var] = new_val; + var_score[var] = own_score; + incumbent_objective += pb.objective[var] * delta; + + if (violated_list.empty() && incumbent_objective < best_objective) { + best_objective = incumbent_objective; + best_assign = assign; + feasible_found = true; + report_incumbent(climber); + } + + const int32_t tenure = + tabu_tenure_min + (int32_t)(rng.next_u32() % (uint32_t)(tabu_tenure_max - tabu_tenure_min)); + tabu.on_flip(var, iters, tenure); + std::fill(var_bitmap.begin(), var_bitmap.end(), (char)0); + } + + // Publish a new best into the climber, which owns the reporting contract. + void report_incumbent(fj_cpu_climber_t& climber) + { + auto& h_assign = climber.h_assignment; + auto& h_best = climber.h_best_assignment; + for (int32_t v = 0; v < pb.n_variables; ++v) { + h_assign[v] = (f_t)assign[v]; + h_best[v] = (f_t)assign[v]; + } + climber.h_incumbent_objective = (f_t)incumbent_objective; + climber.h_best_objective = (f_t)best_objective; + climber.feasible_found = true; + if (climber.improvement_callback) { + const double work_units = climber.work_units_elapsed.load(std::memory_order_acquire); + climber.improvement_callback((f_t)best_objective, h_best, work_units); + } + } + + void reweight_constraint(int32_t r, int32_t new_weight) + { + fj_bin_row_t& h = rows[r]; + if (new_weight == h.weight) return; + h.weight = new_weight; + if (new_weight > max_weight) max_weight = new_weight; + // lhs is unchanged here, and no variable is excluded, so skip_var matches no index. + const int32_t kb = pb.offsets[r], ke = pb.offsets[r + 1]; + fj_bin_patch_row(fj_bin_patch_width_for(ke - kb, narrow4_max, narrow8_max), + pb.variables.data(), + pb.coefficients.data(), + kb, + ke, + var_score.data(), + nnz_score_delta.data(), + assign_i32.data(), + h.sign, + h.weight, + h.sign * (h.bound - h.lhs), + -1); + nnz_touched += ke - kb; + nnz_patched += ke - kb; + } + + // DDFW: every violated row gains weight taken from a satisfied neighbour above the donation + // floor, so total weight is roughly conserved and differentiation stays local to the hard region. + void update_weights() + { + for (int32_t cf : violated_list) { + reweight_constraint(cf, rows[cf].weight + fj_bin_ddfw_transfer); + const int32_t vo = pb.offsets[cf], ve = pb.offsets[cf + 1]; + if (ve <= vo) continue; + int32_t best_donor = -1, best_w = fj_bin_ddfw_init; + for (int32_t s = 0; s < fj_bin_ddfw_donor_samples; ++s) { + const int32_t v = pb.variables[vo + (int32_t)(rng.next_u32() % (uint32_t)(ve - vo))]; + const int32_t no = pb.reverse_offsets[v], ne = pb.reverse_offsets[v + 1]; + if (ne <= no) continue; + const int32_t d = + pb.reverse_constraints[no + (int32_t)(rng.next_u32() % (uint32_t)(ne - no))]; + if (d != cf && !is_violated[d] && rows[d].weight > best_w) { + best_w = rows[d].weight; + best_donor = d; + } + } + if (best_donor >= 0) reweight_constraint(best_donor, rows[best_donor].weight - fj_bin_ddfw_transfer); + } + if (violated_list.empty()) objective_weight += 1; + } + + // Global argmax over every variable, affordable because var_score is maintained live. While the + // objective weight is zero the full score is exactly var_score, which is the vectorized sweep's + // precondition; the objective and local-minimum paths fall to the scalar loop. + std::pair find_move_global(bool localmin) const + { + if (!localmin && objective_weight == 0) { + int32_t v = -1, s = fj_bin_score_invalid; + fj_bin_argmax(var_score.data(), + tabu.flip_until.data(), + pb.n_variables, + (uint16_t)(iters - tabu.base), + argmax_tile, + v, + s); + return {v, s}; + } + + int32_t best_v = -1, best_s = fj_bin_score_invalid; + for (int32_t v = 0; v < pb.n_variables; ++v) { + if (tabu_blocked(v, localmin)) continue; + const int32_t s = full_score(v, (int8_t)(1 - 2 * assign[v])); + if (s > best_s) { + best_s = s; + best_v = v; + } + } + return {best_v, best_s}; + } + + std::pair find_move_in_rows(const std::vector& target_rows, + bool localmin) + { + int32_t best_v = -1, best_s = fj_bin_score_invalid; + for (int32_t r : target_rows) { + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { + const int32_t v = pb.variables[k]; + if (var_bitmap[v]) continue; + var_bitmap[v] = 1; + if (tabu_blocked(v, localmin)) continue; + const int32_t s = full_score(v, (int8_t)(1 - 2 * assign[v])); + if (s > best_s) { + best_s = s; + best_v = v; + } + } + } + return {best_v, best_s}; + } + + std::pair find_move_violated(int32_t sample_size, bool localmin) + { + // Draw the rows directly instead of reservoir-sampling the violated list: `std::sample` is + // linear in the population, so it walked every violated row to keep a handful. Sampling with + // replacement is what `find_move_satisfied` already does, and `find_move_in_rows` deduplicates + // variables through `var_bitmap`, so a repeated row costs a bitmap sweep and no scoring. + const int32_t n = (int32_t)violated_list.size(); + const std::vector* sampled = &violated_list; + if (n > sample_size) { + sample_buf.clear(); + for (int32_t i = 0; i < sample_size; ++i) { + sample_buf.push_back(violated_list[rng.next_u32() % (uint32_t)n]); + } + sampled = &sample_buf; + } + auto move = find_move_in_rows(*sampled, localmin); + + // Breakthrough moves: once a feasible solution exists, allow objective-driven jumps. + if (feasible_found && incumbent_objective >= best_objective + breakthrough_margin) { + for (int32_t v : pb.objective_vars) { + const double step = (best_objective - incumbent_objective) / pb.objective[v]; + double target = pb.objective[v] > 0 ? std::floor(assign[v] + step) + : std::ceil(assign[v] + step); + if (target < 0) target = 0; + if (target > 1) target = 1; + if ((int8_t)target == assign[v]) continue; + if (tabu_blocked(v, false)) continue; + const int32_t s = full_score(v, (int8_t)((int8_t)target - assign[v])); + if (s > move.second) move = {v, s}; + } + } + return move; + } + + std::pair find_move_satisfied(int32_t sample_size) + { + sample_buf.clear(); + for (int32_t tries = 0; (int32_t)sample_buf.size() < sample_size && tries < sample_size * 8; + ++tries) { + const int32_t r = (int32_t)(rng.next_u32() % (uint32_t)pb.n_constraints); + if (!is_violated[r]) sample_buf.push_back(r); + } + return find_move_in_rows(sample_buf, false); + } + + std::pair find_lift_move() const + { + int32_t best_v = -1, best_s = 0; + for (int32_t v : pb.objective_vars) { + const int8_t delta = (int8_t)(1 - 2 * assign[v]); + if ((double)delta * pb.objective[v] >= 0) continue; + if (tabu_blocked(v, false)) continue; + const int32_t s = (int32_t)(-std::llround(pb.objective[v] * delta)) * fj_bin_score_k; + if (s > best_s) { + best_s = s; + best_v = v; + } + } + return {best_v, best_s}; + } + + void perturb() + { + if (pb.objective_vars.empty()) return; + const uint32_t n = (uint32_t)pb.objective_vars.size(); + for (int i = 0; i < 2; ++i) { + const int32_t v = pb.objective_vars[rng.next_u32() % n]; + assign[v] = (int8_t)(rng.next_u32() & 1u); + assign_i32[v] = assign[v]; + } + recompute_lhs(); + } + + // Restart returns the assignment to the seed the climber was constructed with, leaving the + // recorded best and the global iteration counter intact. + void do_restart() + { + assign = seed_assign; + for (int32_t v = 0; v < pb.n_variables; ++v) assign_i32[v] = assign[v]; + for (int32_t r = 0; r < pb.n_constraints; ++r) rows[r].weight = pb.initial_weight[r]; + max_weight = fj_bin_ddfw_init; + objective_weight = 0; + tabu.clear(iters); + recompute_lhs(); + last_restart_iter = iters; + last_feasible_entrance_iter = iters; + } + + void init(fj_cpu_climber_t& climber) + { + const auto& params = climber.settings.parameters; + seed = climber.settings.seed; + narrow4_max = fj_bin_simd_narrow4_max(); + narrow8_max = fj_bin_simd_narrow8_max(); + rng = raft::random::PCGenerator((uint64_t)seed, 0, 0); + tabu_tenure_min = params.tabu_tenure_min; + tabu_tenure_max = params.tabu_tenure_max; + breakthrough_margin = params.breakthrough_move_epsilon; + perturb_interval = climber.perturb_interval; + mtm_viol_samples = climber.mtm_viol_samples; + mtm_sat_samples = climber.mtm_sat_samples; + if (tabu_tenure_max <= tabu_tenure_min) tabu_tenure_max = tabu_tenure_min + 1; + + const int32_t n = pb.n_variables, m = pb.n_constraints; + const auto& h_assign = climber.h_assignment; + assign.resize(n); + for (int32_t v = 0; v < n; ++v) { + const double val = (double)h_assign[v]; + assign[v] = (int8_t)(val >= 0.5 ? 1 : 0); + } + seed_assign = assign; + best_assign = assign; + assign_i32.assign(n, 0); + for (int32_t v = 0; v < n; ++v) assign_i32[v] = assign[v]; + + rows.resize(m); + for (int32_t r = 0; r < m; ++r) + rows[r] = fj_bin_row_t{0, pb.initial_weight[r], pb.bound[r], pb.cmax[r], pb.sign[r]}; + + var_score.assign(n, 0); + nnz_score_delta.assign(pb.nnz + fj_bin_simd_padding(), 0); + tabu.resize(n); + is_violated.assign(m, 0); + vpos.assign(m, -1); + violated_list.clear(); + var_bitmap.assign(n, 0); + + objective_weight = 0; + max_weight = fj_bin_ddfw_init; + incumbent_objective = 0; + best_objective = std::numeric_limits::infinity(); + feasible_found = false; + iters = 0; + last_restart_iter = 0; + recompute_lhs(); + } + + void solve(fj_cpu_climber_t& climber, f_t time_limit, double work_unit_limit) override + { + init(climber); + + const auto loop_start = std::chrono::high_resolution_clock::now(); + const auto limit = + std::chrono::milliseconds((int64_t)std::floor((double)time_limit * 1000.0)); + const bool bounded_time = std::isfinite((double)time_limit); + + while (!climber.halted && !climber.preemption_flag.load()) { + if (bounded_time && std::chrono::high_resolution_clock::now() - loop_start > limit) break; + if (iters >= climber.settings.iteration_limit) break; + if (iters - last_restart_iter >= fj_bin_restart_period) do_restart(); + tabu.maybe_advance(iters); + + int32_t move_var = -1, score = fj_bin_score_invalid; + if (violated_list.empty()) std::tie(move_var, score) = find_lift_move(); + if (score <= 0) std::tie(move_var, score) = find_move_global(false); + if (feasible_found && score <= 0) std::tie(move_var, score) = find_move_satisfied(mtm_sat_samples); + + bool perturb_now = false; + if (violated_list.empty() && iters - last_feasible_entrance_iter > perturb_interval) { + perturb_now = true; + last_feasible_entrance_iter = iters; + } + + if (score > 0 && move_var >= 0 && !perturb_now) { + apply_move(move_var, (int8_t)(1 - 2 * assign[move_var]), climber); + } else { + update_weights(); + if (perturb_now) perturb(); + std::tie(move_var, score) = find_move_violated(1, true); + const int32_t v = move_var >= 0 ? move_var : 0; + apply_move(v, (int8_t)(1 - 2 * assign[v]), climber); + } + + if (iters % climber.log_interval == 0) { + CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] iteration: %d, viol: %zu, best: %g, maxw: %d", + climber.log_prefix.c_str(), + coefficient_bits(), + iters, + violated_list.size(), + best_objective, + max_weight); + } + if (iters % climber.diversity_callback_interval == 0 && climber.diversity_callback) { + auto& h_assign = climber.h_assignment; + for (int32_t v = 0; v < pb.n_variables; ++v) h_assign[v] = (f_t)assign[v]; + climber.diversity_callback((f_t)incumbent_objective, h_assign); + } + + // Work-unit proxy. nnz_touched is cumulative, reproducing the accumulation shape the general + // path gets from its cumulative byte counters. + if (iters % 100 == 0 && iters > 0) { + const double work = (double)nnz_touched * fj_bin_bytes_per_nnz * climber.work_unit_bias / 1e10; + climber.work_units_elapsed.store(work, std::memory_order_release); + if (climber.producer_sync != nullptr) climber.producer_sync->notify_progress(); + if (work >= work_unit_limit) break; + } + + ++iters; + } + + compute_saturation(); + verify_incumbent(climber); + climber.iterations = (i_t)iters; + CUOPT_LOG_DEBUG( + "%sCPUFJ[bin%d] done: %d iterations, best %g, max weight %d, aggregate base %d/%d, bonus %d/%d", + climber.log_prefix.c_str(), + coefficient_bits(), + iters, + best_objective, + max_weight, + max_aggregate_base, + fj_bin_base_limit, + max_aggregate_bonus, + fj_bin_bonus_limit); + CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] work: nnz_patched %lld, rows_walked %lld", + climber.log_prefix.c_str(), + coefficient_bits(), + (long long)nnz_patched, + (long long)rows_walked); + } +}; + +template +void fj_binary_state_deleter_t::operator()(fj_binary_state_t* ptr) const +{ + delete ptr; +} + +template +void try_build_binary_fastpath(fj_cpu_climber_t& climber) +{ + const fj_bin_scan_t scan = fj_bin_scan(climber); + if (scan.reject != fj_binary_reject_t::none) { + CUOPT_LOG_DEBUG("%sCPUFJ binary fast path declined: %s (row %d, var %d)", + climber.log_prefix.c_str(), + fj_binary_reject_name(scan.reject), + scan.bad_row, + scan.bad_var); + return; + } + + bool built = false; + if (scan.coefficient_bits == 8) { + auto engine = std::make_unique>(); + built = fj_bin_narrow(climber, scan.n_split_constraints, engine->pb); + if (built) climber.binary_fast.reset(engine.release()); + } else { + auto engine = std::make_unique>(); + built = fj_bin_narrow(climber, scan.n_split_constraints, engine->pb); + if (built) climber.binary_fast.reset(engine.release()); + } + + if (!built) { + CUOPT_LOG_DEBUG("%sCPUFJ binary fast path declined: %s", + climber.log_prefix.c_str(), + fj_binary_reject_name(fj_binary_reject_t::narrow_check_failed)); + return; + } + + CUOPT_LOG_DEBUG("%sCPUFJ binary fast path enabled: int%d coefficients, %d rows after one-sided split", + climber.log_prefix.c_str(), + scan.coefficient_bits, + scan.n_split_constraints); +} + +#if MIP_INSTANTIATE_FLOAT +template struct fj_binary_state_deleter_t; +template void try_build_binary_fastpath(fj_cpu_climber_t& climber); +#endif + +#if MIP_INSTANTIATE_DOUBLE +template struct fj_binary_state_deleter_t; +template void try_build_binary_fastpath(fj_cpu_climber_t& climber); +#endif + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh new file mode 100644 index 0000000000..fc3415e679 --- /dev/null +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh @@ -0,0 +1,169 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include +#include + +// Seam between the general CPU FJ path and the binary fast path. Only fj_cpu.cu includes this; +// fj_cpu.cuh forward-declares fj_binary_state_t so the climber can hold one without the general +// header depending on the fast path. +// +// The fast path applies to instances whose variables are all binary and whose rows carry integer +// coefficients within int8 or int16 range. On those it runs an integer engine: exact feasibility +// against a single row bound, a live per-variable score patched through stored per-nnz +// contributions, and a global argmax move selection. + +namespace cuopt::mathematical_optimization::mip { + +template +struct fj_cpu_climber_t; + +// Why an instance was refused, logged at DEBUG by the build entry. +enum class fj_binary_reject_t : uint8_t { + none, + empty_problem, + non_binary_var, + fractional_coefficient, + coefficient_out_of_range, + fractional_row_bound, + row_bound_out_of_range, + lhs_headroom, + narrow_check_failed, +}; + +const char* fj_binary_reject_name(fj_binary_reject_t reason); + +// Built state for one eligible instance. The concrete engine is templated on coefficient width +// (int8_t or int16_t) and lives in fj_cpu_binary.cu; the width choice is made once at build time, +// so dispatching through this base costs one virtual call per solve. +template +struct fj_binary_state_t { + virtual ~fj_binary_state_t() = default; + + virtual void solve(fj_cpu_climber_t& climber, + f_t time_limit, + double work_unit_limit) = 0; + + virtual int coefficient_bits() const = 0; // 8 or 16 + virtual int n_split_constraints() const = 0; + virtual i_t iterations() const = 0; + + // Largest per-variable aggregate base and bonus observed at end of solve. The packed score is + // order-preserving only while these stay under 2^16 and 2^14 respectively. + virtual void saturation(int& max_aggregate_base, int& max_aggregate_bonus) const = 0; +}; + +// Runs predicate -> one-sided split -> narrow. A pure function of the climber's host problem +// mirrors and its incoming constraint weights, so it is well defined wherever those are populated. +// Populates climber.binary_fast on success; leaves it empty and logs the reason otherwise. +template +void try_build_binary_fastpath(fj_cpu_climber_t& climber); + +// --------------------------------------------------------------------------------------------- +// Hot kernels and the score-delta formula they share with the engine. +// +// The kernels live in fj_cpu_binary_kernels.cpp, built with Google Highway, which compiles the +// bodies once per SIMD target and dispatches at runtime. That file is host-compiled: nvcc's +// frontend rejects Highway's x86 headers, which reinterpret-cast intrinsic vectors to +// compiler-specific vector types (GCC vector extensions in the constant-folding path, __m128bh +// for bfloat16). Every argument below is a plain pointer or scalar, so the seam names no cuOpt or +// CUDA type. +// --------------------------------------------------------------------------------------------- + +// Packed staged score: one int32 holding base * K + bonus. K exceeds twice the largest |bonus| the +// engine produces, so integer ordering on the packed word reproduces the lexicographic (base, +// bonus) ordering the general path gets from fj_staged_score_t. +constexpr int32_t fj_bin_score_shift = 15; +constexpr int32_t fj_bin_score_k = 1 << fj_bin_score_shift; +constexpr int32_t fj_bin_score_invalid = INT32_MIN; + +// Change in one row's weighted score when one variable flips, from the row's signed slack before +// (os) and after (ns) that flip. base is the weighted change in satisfaction; bonus is the +// weighted change in strict slack. When both states are violated the improving direction earns +// half weight, matching excess_improvement_weight of 1/2. +// +// Single source of the formula: the engine scores moves with it, compute_saturation walks it, and +// the vector kernels reproduce it lane-wise. +static inline void fj_bin_score_delta_parts( + int32_t os, int32_t ns, int32_t weight, int32_t& base, int32_t& bonus) +{ + const int32_t osat = os >= 0, nsat = ns >= 0; + const int32_t ost = os > 0, nst = ns > 0; + const int32_t improving = (os < ns) - (ns < os); + base = weight * (nsat - osat) + (1 - osat) * (1 - nsat) * improving * (weight / 2); + bonus = weight * (nst - ost); +} + +static inline int32_t fj_bin_packed_score_delta(int32_t os, int32_t ns, int32_t weight) +{ + int32_t base = 0, bonus = 0; + fj_bin_score_delta_parts(os, ns, weight, base, bonus); + return base * fj_bin_score_k + bonus; +} + +// Elements of padding the per-nnz arrays must carry past nnz, in int32 units. A target that masks +// its row remainder loads and stores a whole vector at the last row of the matrix, and the padding +// is what keeps that off memory it does not own. A target that peels the remainder into a scalar +// tail stops at the row end and asks for nothing, so this returns 0 there. +int32_t fj_bin_simd_padding(); + +// Vector width the row patch runs at. A gather costs the same whether its lanes carry data or are +// masked off, so a row filling only part of a native vector is cheaper through a narrower one; past +// the crossover the extra vector and its extra full gather cost more than the idle lanes. +enum class fj_bin_patch_width_t : int32_t { narrow4 = 0, narrow8 = 1, native = 2 }; + +// Longest row for which each narrower width beats the native one, or 0 where that width is not +// worth offering on this target: a width is offered only when it is strictly narrower than the +// native vector, and scalable targets decline both. +int32_t fj_bin_simd_narrow4_max(); +int32_t fj_bin_simd_narrow8_max(); + +static inline fj_bin_patch_width_t fj_bin_patch_width_for(int32_t row_len, + int32_t narrow4_max, + int32_t narrow8_max) +{ + if (row_len <= narrow4_max) return fj_bin_patch_width_t::narrow4; + if (row_len <= narrow8_max) return fj_bin_patch_width_t::narrow8; + return fj_bin_patch_width_t::native; +} + +// Patch every variable of one row against the row's current signed slack, skipping skip_var. The +// move case passes the post-move slack and the flipped variable's index; the reweight case passes +// the unchanged slack and -1, which matches no variable index. +// +// Reads and writes up to fj_bin_simd_padding() elements from kb, so variables, coefficients and +// nnz_score_delta must carry that padding. +// +// Defined in the host-compiled kernels TU and explicitly instantiated there for int8_t and int16_t. +template +void fj_bin_patch_row(fj_bin_patch_width_t width, + const int32_t* variables, + const coef_t* coefficients, + int32_t kb, + int32_t ke, + int32_t* var_score, + int32_t* nnz_score_delta, + const int32_t* assign_i32, + int32_t sign, + int32_t weight, + int32_t os_new, + int32_t skip_var); + +// Argmax over var_score with the tabu window folded in, scanning all n variables. Valid while the +// objective weight is zero, where the full score is exactly var_score. Yields best_var of -1 when +// every variable is tabu. +void fj_bin_argmax(const int32_t* var_score, + const uint16_t* flip_until, + int32_t n, + uint16_t iter_biased, + int32_t tile, + int32_t& best_var, + int32_t& best_score); + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp new file mode 100644 index 0000000000..a91858beff --- /dev/null +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp @@ -0,0 +1,449 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +// Hot kernels of the binary CPU FJ fast path, vectorized with Google Highway. foreach_target.h +// re-includes this file once per SIMD target; HWY_EXPORT builds the dispatch table and +// HWY_DYNAMIC_DISPATCH picks at runtime. Host-compiled rather than nvcc-compiled: nvcc's frontend +// rejects Highway's x86 headers, which reinterpret-cast intrinsic vectors to compiler-specific +// vector types. + +#include + +#include +#include + +#undef HWY_TARGET_INCLUDE +#define HWY_TARGET_INCLUDE "mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp" +#include "hwy/foreach_target.h" // must precede highway.h +#include "hwy/highway.h" + +HWY_BEFORE_NAMESPACE(); +namespace cuopt::mathematical_optimization::mip { +namespace HWY_NAMESPACE { + +namespace hn = hwy::HWY_NAMESPACE; + +// Whether the row remainder is masked into the vector body or peeled into a scalar tail. AVX-512 +// k-registers, SVE predicates and RVV masks make every operation maskable at no cost, so a row of +// three nonzeros is one masked iteration; peeling it would send most of the work to the tail, since +// row lengths are short and have nothing to do with the lane count. AVX2 and NEON have no mask +// registers: the mask becomes a vector, gather and scatter are emulated, and the tail is cheaper. +// Measured on AVX2, masking the remainder cost 6.8% on supportcase22 and 12.9% on bnatt400. +constexpr bool k_mask_remainder = + (HWY_TARGET <= HWY_AVX3) || HWY_TARGET_IS_SVE || (HWY_TARGET == HWY_RVV); + +// Padding the caller must carry past nnz. Only a masking target reads past a row's end; a peeling +// target stops at ke, so it asks for nothing and its buffers keep their natural size. +int32_t PaddingImpl() +{ + return k_mask_remainder ? (int32_t)hn::Lanes(hn::ScalableTag()) : 0; +} + +// Row remainder when it is peeled rather than masked, and the whole row on scalar targets. +template +void PatchRowScalar(const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int32_t* HWY_RESTRICT var_score, + int32_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t sign, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + for (int32_t k = kb; k < ke; ++k) { + const int32_t v = variables[k]; + if (v == skip_var) continue; + const int32_t flip = 1 - 2 * assign_i32[v]; + const int32_t ns = os_new - sign * ((int32_t)coefficients[k] * flip); + const int32_t nc = fj_bin_packed_score_delta(os_new, ns, weight); + var_score[v] += nc - nnz_score_delta[k]; + nnz_score_delta[k] = nc; + } +} + +// Templated on the vector tag so one body serves both the native-width kernel and the narrow one. +// Rows here average well under a native 512-bit vector, and a gather costs the same whether its +// lanes are used or discarded, so short rows are cheaper through a narrower vector. +template +static HWY_INLINE void PatchRowBody(D d, + const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int32_t* HWY_RESTRICT var_score, + int32_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t sign, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + const hn::Rebind dc; // same lane count, narrower lanes + using V = hn::Vec; + const size_t N = hn::Lanes(d); + + // When the remainder is peeled, a row below one vector never reaches the body, so it skips the + // ten broadcasts below as well. + if constexpr (!k_mask_remainder) { + if ((size_t)(ke - kb) < N) { + PatchRowScalar(variables, coefficients, kb, ke, var_score, nnz_score_delta, + assign_i32, sign, weight, os_new, skip_var); + return; + } + } + + const V vone = hn::Set(d, 1), vzero = hn::Zero(d); + const V vsign = hn::Set(d, sign), vskip = hn::Set(d, skip_var); + const V vos = hn::Set(d, os_new); + const V vw = hn::Set(d, weight), vw2 = hn::Set(d, weight / 2); + + // The row's own slack is uniform across lanes, so its flags are scalars. + const int32_t osat = os_new >= 0, ost = os_new > 0; + const V vosat = hn::Set(d, osat), vost = hn::Set(d, ost); + const V v_not_osat = hn::Set(d, 1 - osat); + + // The loads always run unmasked and read into the per-nnz padding; when the remainder is masked, + // FirstN keeps the overhang out of the gather, the scatter and the store. + const int32_t vec_end = k_mask_remainder ? ke : ke - (int32_t)N + 1; + int32_t k = kb; + for (; k < vec_end; k += (int32_t)N) { + const V v = hn::LoadU(d, variables + k); + auto active = hn::Ne(v, vskip); + if constexpr (k_mask_remainder) { + active = hn::And(active, hn::FirstN(d, (size_t)(ke - k))); + } + + // Gathered in hardware even on Zen 4, unlike the score update below. Doing this one by lane + // instead measured 8.2% slower: it must spill the index vector and reload it 4 bytes at a time, + // which cannot store-to-load forward, and that cost 959 interlocks per iteration against 72. + // The score update escapes this because it already needs the spill for its read-modify-write. + const V a01 = hn::MaskedGatherIndex(active, d, assign_i32, v); + const V flip = vone - hn::ShiftLeft<1>(a01); + const V coef = hn::PromoteTo(d, hn::LoadU(dc, coefficients + k)); + + const V ns = vos - vsign * coef * flip; + + const V nsat = hn::IfThenElseZero(hn::Ge(ns, vzero), vone); + const V nst = hn::IfThenElseZero(hn::Gt(ns, vzero), vone); + const V improving = + hn::IfThenElseZero(hn::Gt(ns, vos), vone) - hn::IfThenElseZero(hn::Lt(ns, vos), vone); + + const V both_violated = v_not_osat * (vone - nsat); + const V base = vw * (nsat - vosat) + both_violated * improving * vw2; + const V bonus = vw * (nst - vost); + const V packed_new = hn::ShiftLeft(base) + bonus; + + const V delta = packed_new - hn::LoadU(d, nnz_score_delta + k); + +#if HWY_TARGET == HWY_AVX3_ZEN4 + // zmm VSIB is microcode on Zen 4: VPGATHERDD ~76-80 uops / ~21 CPI and VPSCATTERDD 89 / 24, + // against ~5 / ~10 and ~19 / ~11 on SPR-class Intel (Agner Fog, uops.info). So read-modify-write + // by lane here; measured +5.8% over the arm below on an EPYC 9554 (supportcase22, 16 climbers). + HWY_ALIGN int32_t idx[hn::MaxLanes(d)], dl[hn::MaxLanes(d)]; + hn::Store(v, d, idx); + hn::Store(delta, d, dl); + // Bounded by the row, not the vector: the lanes past it hold padding, whose zero index would + // otherwise be applied to variable 0. + const size_t lanes = HWY_MIN(N, (size_t)(ke - k)); + for (size_t i = 0; i < lanes; ++i) { + if (idx[i] != skip_var) var_score[idx[i]] += dl[i]; + } +#else + const V current = hn::MaskedGatherIndex(active, d, var_score, v); + hn::MaskedScatterIndex(current + delta, active, d, var_score, v); +#endif + + hn::BlendedStore(packed_new, active, d, nnz_score_delta + k); + } + + if constexpr (!k_mask_remainder) { + PatchRowScalar(variables, coefficients, k, ke, var_score, nnz_score_delta, assign_i32, + sign, weight, os_new, skip_var); + } +} + +// Native width, and the 8-lane variant for rows that would leave most of a native vector idle. +template +void PatchRowImpl(const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int32_t* HWY_RESTRICT var_score, + int32_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t sign, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + PatchRowBody(hn::ScalableTag(), variables, coefficients, kb, ke, var_score, + nnz_score_delta, assign_i32, sign, weight, os_new, skip_var); +} + +template +void PatchRowNarrow8Impl(const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int32_t* HWY_RESTRICT var_score, + int32_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t sign, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + PatchRowBody(hn::CappedTagIfFixed(), variables, coefficients, kb, ke, + var_score, nnz_score_delta, assign_i32, sign, weight, os_new, skip_var); +} + +template +void PatchRowNarrow4Impl(const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int32_t* HWY_RESTRICT var_score, + int32_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t sign, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + PatchRowBody(hn::CappedTagIfFixed(), variables, coefficients, kb, ke, + var_score, nnz_score_delta, assign_i32, sign, weight, os_new, skip_var); +} + +// Longest row worth sending to each narrower kernel, or 0 where that width is not worth having. +// A gather costs the same whether its lanes carry data or are masked off, so a row that fills only +// part of a native vector is cheaper through a narrower one; past the crossover the extra vector +// and its extra full gather cost more than the wasted lanes. From the Zen 4 microcode ratio +// (VPGATHERDD ~78 uops at 512 bits, 48 at 256, 24 at 128) the crossovers land at 4 and 8. +// +// A width is offered only when it is strictly narrower than the native vector, so no target ever +// dispatches to a kernel identical to its own. Scalable targets opt out entirely: Highway notes +// that clamping Lanes() on RVV/SVE can cost more than the capping saves, which is why +// CappedTagIfFixed leaves them at native width above. +int32_t Narrow4MaxImpl() +{ + if (HWY_HAVE_SCALABLE) return 0; + return hn::Lanes(hn::ScalableTag()) > 4 ? 4 : 0; +} + +int32_t Narrow8MaxImpl() +{ + if (HWY_HAVE_SCALABLE) return 0; + return hn::Lanes(hn::ScalableTag()) > 8 ? 8 : 0; +} + +// Tiled sweep carrying a running maximum. The index re-scan fires only on a tile that raises it, +// and that tile is still cache-hot. The tabu window is uint16 against int32 scores, so the mask +// crosses a 2:1 width boundary through PromoteMaskTo. +void ArgmaxImpl(const int32_t* HWY_RESTRICT var_score, + const uint16_t* HWY_RESTRICT flip_until, + int32_t n, + uint16_t iter_biased, + int32_t tile, + int32_t* best_var, + int32_t* best_score) +{ + const hn::ScalableTag d; + const hn::Rebind d16; + using V = hn::Vec; + + const int32_t step = (int32_t)hn::Lanes(d); + const V vmin = hn::Set(d, fj_bin_score_invalid); + const auto viter = hn::Set(d16, iter_biased); + + // Whole vectors only; the remainder is scanned scalar below. + const int32_t nblk = n - (n % step); + int32_t tile_step = tile - (tile % step); + if (tile_step < step) tile_step = step; + + int32_t bv = -1, bs = fj_bin_score_invalid; + + for (int32_t t0 = 0; t0 < nblk; t0 += tile_step) { + const int32_t t1 = (t0 + tile_step < nblk) ? t0 + tile_step : nblk; + + V tile_max = vmin; + for (int32_t v = t0; v < t1; v += step) { + const auto tabu = + hn::PromoteMaskTo(d, d16, hn::Lt(viter, hn::LoadU(d16, flip_until + v))); + tile_max = hn::Max(tile_max, hn::IfThenElse(tabu, vmin, hn::LoadU(d, var_score + v))); + } + + const int32_t peak = hn::ReduceMax(d, tile_max); + if (peak > bs) { + const V vpeak = hn::Set(d, peak); + for (int32_t v = t0; v < t1; v += step) { + const auto tabu = + hn::PromoteMaskTo(d, d16, hn::Lt(viter, hn::LoadU(d16, flip_until + v))); + const V s = hn::IfThenElse(tabu, vmin, hn::LoadU(d, var_score + v)); + const intptr_t lane = hn::FindFirstTrue(d, hn::Eq(s, vpeak)); + if (lane >= 0) { + bv = v + (int32_t)lane; + break; + } + } + bs = peak; + } + } + + for (int32_t v = nblk; v < n; ++v) { + if (iter_biased < flip_until[v]) continue; + if (var_score[v] > bs) { + bs = var_score[v]; + bv = v; + } + } + + *best_var = bv; + *best_score = bs; +} + +} // namespace HWY_NAMESPACE +} // namespace cuopt::mathematical_optimization::mip +HWY_AFTER_NAMESPACE(); + +#if HWY_ONCE +namespace cuopt::mathematical_optimization::mip { + +// One dispatch table per (coefficient width, vector width). HWY_EXPORT_T names the table +// separately from the function, which lets the function be a template-id: only the table name goes +// through token pasting, so no hand-written non-template wrapper is needed. The template argument +// must stay comma-free, which is why the three tag-binding wrappers above take only coef_t. +HWY_EXPORT_T(PatchRowNatI8, PatchRowImpl); +HWY_EXPORT_T(PatchRowN8I8, PatchRowNarrow8Impl); +HWY_EXPORT_T(PatchRowN4I8, PatchRowNarrow4Impl); +HWY_EXPORT_T(PatchRowNatI16, PatchRowImpl); +HWY_EXPORT_T(PatchRowN8I16, PatchRowNarrow8Impl); +HWY_EXPORT_T(PatchRowN4I16, PatchRowNarrow4Impl); +HWY_EXPORT(ArgmaxImpl); +HWY_EXPORT(PaddingImpl); +HWY_EXPORT(Narrow4MaxImpl); +HWY_EXPORT(Narrow8MaxImpl); + +// HWY_DYNAMIC_DISPATCH resolves the target on every call, and the hwy::GetChosenTarget() call it +// expands to is a real out-of-line call: it clobbers the argument registers, so the compiler spills +// all eleven parameters to the stack and reloads them around it. These run once per row per move, +// so the pointers are resolved once instead. +// +// Entry 0 of a dispatch table is a trampoline that chooses the target and re-dispatches, and an +// unchosen target makes GetIndex() return 0. Caching then would pin that extra indirection for the +// process lifetime, so the target is chosen first. File scope rather than function scope keeps the +// guard variable of a magic static out of the call: its cold path can call __cxa_guard_acquire, so +// the compiler must preserve the arguments across it and cannot leave a bare tail jump. Nothing in +// cuOpt reaches feasibility jump during static initialization. +static void fj_bin_choose_target() +{ + if (!hwy::GetChosenTarget().IsInitialized()) { + hwy::GetChosenTarget().Update(hwy::SupportedTargets()); + } +} + +template +using fj_bin_patch_fn_t = void (*)(const int32_t*, + const coef_t*, + int32_t, + int32_t, + int32_t*, + int32_t*, + const int32_t*, + int32_t, + int32_t, + int32_t, + int32_t); + +// Indexed by fj_bin_patch_width_t. +static const fj_bin_patch_fn_t fj_bin_patch_i8[3] = { + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowN4I8)), + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowN8I8)), + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowNatI8)), +}; + +static const fj_bin_patch_fn_t fj_bin_patch_i16[3] = { + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowN4I16)), + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowN8I16)), + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowNatI16)), +}; + +// Overloaded rather than specialized so the tables stay plain arrays. +static const fj_bin_patch_fn_t* fj_bin_patch_table(int8_t) { return fj_bin_patch_i8; } +static const fj_bin_patch_fn_t* fj_bin_patch_table(int16_t) { return fj_bin_patch_i16; } + +static const auto fj_bin_argmax_fn = (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(ArgmaxImpl)); +static const auto fj_bin_padding_fn = (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(PaddingImpl)); +static const auto fj_bin_narrow4_max_fn = + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(Narrow4MaxImpl)); +static const auto fj_bin_narrow8_max_fn = + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(Narrow8MaxImpl)); + +int32_t fj_bin_simd_padding() { return fj_bin_padding_fn(); } +int32_t fj_bin_simd_narrow4_max() { return fj_bin_narrow4_max_fn(); } +int32_t fj_bin_simd_narrow8_max() { return fj_bin_narrow8_max_fn(); } + +template +void fj_bin_patch_row(fj_bin_patch_width_t width, + const int32_t* variables, + const coef_t* coefficients, + int32_t kb, + int32_t ke, + int32_t* var_score, + int32_t* nnz_score_delta, + const int32_t* assign_i32, + int32_t sign, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + fj_bin_patch_table(coef_t{})[(int)width](variables, coefficients, kb, ke, var_score, + nnz_score_delta, assign_i32, sign, weight, os_new, + skip_var); +} + +template void fj_bin_patch_row(fj_bin_patch_width_t, + const int32_t*, + const int8_t*, + int32_t, + int32_t, + int32_t*, + int32_t*, + const int32_t*, + int32_t, + int32_t, + int32_t, + int32_t); + +template void fj_bin_patch_row(fj_bin_patch_width_t, + const int32_t*, + const int16_t*, + int32_t, + int32_t, + int32_t*, + int32_t*, + const int32_t*, + int32_t, + int32_t, + int32_t, + int32_t); + +void fj_bin_argmax(const int32_t* var_score, + const uint16_t* flip_until, + int32_t n, + uint16_t iter_biased, + int32_t tile, + int32_t& best_var, + int32_t& best_score) +{ + fj_bin_argmax_fn(var_score, flip_until, n, iter_biased, tile, &best_var, &best_score); +} + +} // namespace cuopt::mathematical_optimization::mip +#endif // HWY_ONCE diff --git a/cpp/src/utilities/version_info.cpp b/cpp/src/utilities/version_info.cpp index 71dfc20c22..2b1d3919d9 100644 --- a/cpp/src/utilities/version_info.cpp +++ b/cpp/src/utilities/version_info.cpp @@ -12,6 +12,9 @@ #include #include +#include +#include + #include #include #include @@ -143,6 +146,23 @@ static std::string get_cpu_model() return "Unknown"; } +// The SIMD instruction set the vectorized CPU kernels actually dispatched to, which reflects the +// compiled target set as well as what this CPU supports. Highway names the AVX-512 family AVX3, +// with AVX3_DL / AVX3_ZEN4 / AVX3_SPR / AVX10_2 marking successively newer feature sets; those are +// reported under the name users know them by. +static const char* get_simd_target() +{ + const int64_t target = hwy::DispatchedTarget(); + switch (target) { + case HWY_AVX3: + case HWY_AVX3_DL: + case HWY_AVX3_ZEN4: + case HWY_AVX3_SPR: + case HWY_AVX10_2: return "AVX-512"; + default: return hwy::TargetName(target); + } +} + struct host_memory_info_t { double total_gb{}; double available_gb{}; @@ -201,6 +221,7 @@ void print_version_info(int num_devices) std::thread::hardware_concurrency(), memory.available_gb, memory.total_gb); + CUOPT_LOG_INFO("CPU SIMD target: %s", get_simd_target()); for (int device_id = 0; device_id < num_devices; ++device_id) { cudaDeviceProp device_prop{}; From ac9d47e5b960c7ca92854f4b4ad6b265c66470d9 Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Thu, 6 Aug 2026 05:44:15 -0700 Subject: [PATCH 19/78] Take the tabu test out of the binary fast path's global argmax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep read two bytes of tenure per variable to answer a question that is true for at most a dozen of them: one variable flips per iteration and tenure is bounded, so only that many are ever tabu. It now reads var_score alone and the caller holds the tabu few at the invalid sentinel across the call, which is the same value the masked select wrote before. That drops the scan from six bytes per variable to four. On crypt16 it takes the argmax working set from 31 KB, which overflows a 32 KB L1, to 21 KB, which does not, and total L2 requests fall 59% (685 to 256 per iteration) — more than the byte count alone predicts, because below the threshold the var_score reads stop missing too. ArgmaxImpl goes from 31.2% of cycles to 23.0%. The tabu set is a ring of the last 16 flips indexed by iteration rather than by expiry. Indexing by expiry is cheaper — position encodes the deadline, so aging is a shift and no comparison is needed — but two flips can share a deadline and the later would silently displace the earlier; simulation over the [3,12] tenure range puts that at 35% of insertions. Indexing by iteration cannot collide, since exactly one flip happens per iteration. on_flip drops any earlier entry for the variable before inserting. Without that the ring holds a superseded deadline: flip_until keeps one deadline per variable and a reflip overwrites it, possibly with an earlier one, while the ring would keep both and block on the later. A variable can be reflipped while still tabu because the local-minimum path tests the weaker iter == last_flip + 1. The first version of this patch missed it and blocked too much, which showed up as fewer crossings at higher throughput; with the fix the crypt16 trajectory is identical to before, the same ten climbers crossing 10-12% earlier. Restores run in reverse: a variable flipped twice inside the ring appears twice, and its second save holds the sentinel written by the first. Measured at 16 climbers: crypt16 +8.4%, supportcase22 +1.1%, bnatt400 -2.9%. The gain tracks n_variables, since it is a residency effect; bnatt400's 401 variables were already resident and only pay the ring's bookkeeping. Co-Authored-By: Claude Opus 5 --- .../feasibility_jump/fj_cpu_binary.cu | 93 +++++++++++++++++-- .../feasibility_jump/fj_cpu_binary.cuh | 11 ++- .../fj_cpu_binary_kernels.cpp | 18 +--- 3 files changed, 94 insertions(+), 28 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 5c5a28d9f5..d4eeab6ac3 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -56,11 +56,25 @@ struct fj_bin_tabu_t { std::vector last_flip; int32_t base{0}; + // Ring of the last ring_size flips, indexed by iteration rather than by expiry. One variable + // flips per iteration, so each slot takes exactly one entry and nothing is ever displaced while + // still tabu; indexing by expiry instead would collide whenever two flips share a deadline, which + // a simulation over the [3,12] tenure range puts at 35% of insertions. + // + // This is what lets the global argmax stop reading flip_until. Tenure is bounded by the ring, so + // at most ring_size variables are tabu at once out of n: reading two bytes per variable to answer + // a question that is almost always no costs a third of that scan's traffic. On crypt16 it is the + // difference between a 31 KB working set that overflows a 32 KB L1 and a 21 KB one that does not. + static constexpr int32_t ring_size = 16; + int32_t ring_var[ring_size]; + int32_t ring_expiry[ring_size]; + void resize(int32_t n) { flip_until.assign(n, 0); last_flip.assign(n, 0); + clear_ring(); base = 0; } @@ -68,13 +82,67 @@ struct fj_bin_tabu_t { { std::fill(flip_until.begin(), flip_until.end(), (uint16_t)0); std::fill(last_flip.begin(), last_flip.end(), 0); + clear_ring(); base = iter; } + void clear_ring() + { + for (int32_t i = 0; i < ring_size; ++i) { + ring_var[i] = -1; + ring_expiry[i] = 0; + } + } + void on_flip(int32_t v, int32_t iter, int32_t tenure) { flip_until[v] = (uint16_t)(iter + tenure - base); last_flip[v] = iter; + + // Drop any earlier entry for v before inserting the new one. flip_until keeps one deadline per + // variable and a reflip overwrites it, so without this the ring would hold a superseded, later + // deadline and block v past the point the per-variable test would have released it. A variable + // can be reflipped while still tabu: the local-minimum path tests the weaker + // iter == last_flip + 1. With this the ring holds at most one live entry per variable carrying + // its current deadline, which is exactly the invariant flip_until maintains. + for (int32_t i = 0; i < ring_size; ++i) { + if (ring_var[i] == v) ring_var[i] = -1; + } + + const int32_t slot = iter & (ring_size - 1); + ring_var[slot] = v; + ring_expiry[slot] = iter + tenure; + } + + // Blocks every currently-tabu variable by writing the invalid sentinel over its score, and reports + // how many were touched. Paired with unblock around one argmax and nothing else: the score is + // maintained incrementally, so it may only be disturbed across a window in which no patch runs. + int32_t block_tabu(int32_t iter, + int32_t* var_score, + int32_t (&saved_var)[ring_size], + int32_t (&saved_score)[ring_size]) const + { + int32_t k = 0; + for (int32_t i = 0; i < ring_size; ++i) { + const int32_t v = ring_var[i]; + if (v >= 0 && ring_expiry[i] > iter) { + saved_var[k] = v; + saved_score[k] = var_score[v]; + var_score[v] = fj_bin_score_invalid; + ++k; + } + } + return k; + } + + // Reverse order on purpose: a variable flipped twice inside the ring appears twice, and its second + // save holds the sentinel written by the first. Unwinding backwards restores the true score last. + static void unblock_tabu(int32_t k, + int32_t* var_score, + const int32_t (&saved_var)[ring_size], + const int32_t (&saved_score)[ring_size]) + { + for (int32_t i = k - 1; i >= 0; --i) var_score[saved_var[i]] = saved_score[i]; } @@ -784,17 +852,18 @@ struct fj_bin_engine_t : fj_binary_state_t { // Global argmax over every variable, affordable because var_score is maintained live. While the // objective weight is zero the full score is exactly var_score, which is the vectorized sweep's // precondition; the objective and local-minimum paths fall to the scalar loop. - std::pair find_move_global(bool localmin) const + std::pair find_move_global(bool localmin) { if (!localmin && objective_weight == 0) { + // The sweep reads var_score alone; the handful of tabu variables are held at the invalid + // sentinel across it rather than tested per variable. + int32_t saved_var[fj_bin_tabu_t::ring_size], saved_score[fj_bin_tabu_t::ring_size]; + const int32_t blocked = tabu.block_tabu(iters, var_score.data(), saved_var, saved_score); + int32_t v = -1, s = fj_bin_score_invalid; - fj_bin_argmax(var_score.data(), - tabu.flip_until.data(), - pb.n_variables, - (uint16_t)(iters - tabu.base), - argmax_tile, - v, - s); + fj_bin_argmax(var_score.data(), pb.n_variables, argmax_tile, v, s); + + fj_bin_tabu_t::unblock_tabu(blocked, var_score.data(), saved_var, saved_score); return {v, s}; } @@ -933,6 +1002,14 @@ struct fj_bin_engine_t : fj_binary_state_t { mtm_sat_samples = climber.mtm_sat_samples; if (tabu_tenure_max <= tabu_tenure_min) tabu_tenure_max = tabu_tenure_min + 1; + // The tabu ring is indexed by iteration modulo its size, so a slot is reused after ring_size + // iterations. A tenure that long would be overwritten while the variable is still tabu, and the + // argmax would stop excluding it. Clamped as well as asserted: release builds compile the assert + // out, and silently dropping tabu entries is worse than a shorter tenure. + cuopt_assert(tabu_tenure_max <= fj_bin_tabu_t::ring_size, + "tabu tenure exceeds the tabu ring, live entries would be evicted"); + if (tabu_tenure_max > fj_bin_tabu_t::ring_size) tabu_tenure_max = fj_bin_tabu_t::ring_size; + const int32_t n = pb.n_variables, m = pb.n_constraints; const auto& h_assign = climber.h_assignment; assign.resize(n); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh index fc3415e679..6fcb53eea5 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh @@ -155,13 +155,14 @@ void fj_bin_patch_row(fj_bin_patch_width_t width, int32_t os_new, int32_t skip_var); -// Argmax over var_score with the tabu window folded in, scanning all n variables. Valid while the -// objective weight is zero, where the full score is exactly var_score. Yields best_var of -1 when -// every variable is tabu. +// Argmax over var_score, scanning all n variables. Valid while the objective weight is zero, where +// the full score is exactly var_score. Yields best_var of -1 only if n is 0. +// +// Carries no tabu argument: at most a ring's worth of variables are tabu at once, so the caller +// holds those few at fj_bin_score_invalid across the call instead of making this read a per-variable +// tenure array. That keeps the scan to four bytes per variable. void fj_bin_argmax(const int32_t* var_score, - const uint16_t* flip_until, int32_t n, - uint16_t iter_biased, int32_t tile, int32_t& best_var, int32_t& best_score); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp index a91858beff..64541a8777 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp @@ -247,20 +247,16 @@ int32_t Narrow8MaxImpl() // and that tile is still cache-hot. The tabu window is uint16 against int32 scores, so the mask // crosses a 2:1 width boundary through PromoteMaskTo. void ArgmaxImpl(const int32_t* HWY_RESTRICT var_score, - const uint16_t* HWY_RESTRICT flip_until, int32_t n, - uint16_t iter_biased, int32_t tile, int32_t* best_var, int32_t* best_score) { const hn::ScalableTag d; - const hn::Rebind d16; using V = hn::Vec; const int32_t step = (int32_t)hn::Lanes(d); const V vmin = hn::Set(d, fj_bin_score_invalid); - const auto viter = hn::Set(d16, iter_biased); // Whole vectors only; the remainder is scanned scalar below. const int32_t nblk = n - (n % step); @@ -274,19 +270,14 @@ void ArgmaxImpl(const int32_t* HWY_RESTRICT var_score, V tile_max = vmin; for (int32_t v = t0; v < t1; v += step) { - const auto tabu = - hn::PromoteMaskTo(d, d16, hn::Lt(viter, hn::LoadU(d16, flip_until + v))); - tile_max = hn::Max(tile_max, hn::IfThenElse(tabu, vmin, hn::LoadU(d, var_score + v))); + tile_max = hn::Max(tile_max, hn::LoadU(d, var_score + v)); } const int32_t peak = hn::ReduceMax(d, tile_max); if (peak > bs) { const V vpeak = hn::Set(d, peak); for (int32_t v = t0; v < t1; v += step) { - const auto tabu = - hn::PromoteMaskTo(d, d16, hn::Lt(viter, hn::LoadU(d16, flip_until + v))); - const V s = hn::IfThenElse(tabu, vmin, hn::LoadU(d, var_score + v)); - const intptr_t lane = hn::FindFirstTrue(d, hn::Eq(s, vpeak)); + const intptr_t lane = hn::FindFirstTrue(d, hn::Eq(hn::LoadU(d, var_score + v), vpeak)); if (lane >= 0) { bv = v + (int32_t)lane; break; @@ -297,7 +288,6 @@ void ArgmaxImpl(const int32_t* HWY_RESTRICT var_score, } for (int32_t v = nblk; v < n; ++v) { - if (iter_biased < flip_until[v]) continue; if (var_score[v] > bs) { bs = var_score[v]; bv = v; @@ -435,14 +425,12 @@ template void fj_bin_patch_row(fj_bin_patch_width_t, int32_t); void fj_bin_argmax(const int32_t* var_score, - const uint16_t* flip_until, int32_t n, - uint16_t iter_biased, int32_t tile, int32_t& best_var, int32_t& best_score) { - fj_bin_argmax_fn(var_score, flip_until, n, iter_biased, tile, &best_var, &best_score); + fj_bin_argmax_fn(var_score, n, tile, &best_var, &best_score); } } // namespace cuopt::mathematical_optimization::mip From 4f4bcb1d461c5c92042fc81ce73df9ac772045d5 Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Thu, 6 Aug 2026 09:47:06 -0700 Subject: [PATCH 20/78] perf(cpufj-bin): drop the per-iteration bitmap clear and hoist apply_move's base pointers Two findings from a Zen 4 profile of supportcase22 (16 climbers, 20 s): 3.537M -> 3.619M aggregate iterations/s, +2.3%. apply_move ended by clearing var_bitmap in full, which GCC emitted as a tail jump to memset: 1.50% of the run spent zeroing 6,489 bytes per iteration for a guard that only find_move_in_rows reads, on the sampling paths. That function now clears the entries it set, which costs 0.20%. This is not bit-identical. find_move_satisfied and find_move_violated can both run in one iteration, and the second used to inherit the first's dedup bits and skip variables it had already seen. The carryover was accidental -- the comment there only justifies dedup within a single call -- but it was real behaviour. Only a climber that has crossed diverges; on supportcase22 the crossings hold at 1/16 and the objective moves 117 -> 116. The loop in apply_move also reloaded eleven .data() pointers out of `this` per row visit, the top three at ~1.4% each. That is aliasing, not register pressure: the body writes h.lhs through fj_bin_row_t* and nnz_score_delta[..] through int32_t*, either of which may alias a vector's internal pointer. Hoisting them into const locals removes every this-relative load from the loop and lets GCC walk reverse_constraints and reverse_coefficients by pointer induction. +1.0%, trajectory bit-identical. Co-Authored-By: Claude Opus 5 --- .../feasibility_jump/fj_cpu_binary.cu | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index d4eeab6ac3..5b2ffae2eb 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -475,6 +475,8 @@ struct fj_bin_engine_t : fj_binary_state_t { std::vector is_violated; std::vector violated_list; std::vector vpos; + // Duplicate guard for find_move_in_rows, its only reader. Zero everywhere outside that function, + // which clears what it set before returning. std::vector var_bitmap; // One generator advanced across the whole search, rather than one re-seeded per call site per @@ -704,14 +706,29 @@ struct fj_bin_engine_t : fj_binary_state_t { const int32_t prev_violated = (int32_t)violated_list.size(); int32_t own_score = 0; + // The loop writes a row's lhs through fj_bin_row_t* and a score delta through int32_t*, either + // of which may alias a vector's internal pointer as far as the compiler can prove. Without + // these locals it reloads every base pointer below out of `this` on each row visit, and cannot + // turn the index walks into pointer inductions. + fj_bin_row_t* const rows_p = rows.data(); + const int32_t* const rcon_p = pb.reverse_constraints.data(); + const coef_t* const rcoef_p = pb.reverse_coefficients.data(); + const int32_t* const rcsr_p = pb.reverse_to_csr.data(); + const int32_t* const offsets_p = pb.offsets.data(); + const int32_t* const vars_p = pb.variables.data(); + const coef_t* const coefs_p = pb.coefficients.data(); + int32_t* const var_score_p = var_score.data(); + int32_t* const nnz_delta_p = nnz_score_delta.data(); + const int32_t* const assign_p = assign_i32.data(); + for (int32_t ii = ob; ii < oe; ++ii) { // Write hint: the row's lhs is updated at the end of every iteration, so the line is wanted // exclusive. The padding on reverse_constraints makes the lookahead unconditional. - __builtin_prefetch(&rows[pb.reverse_constraints[ii + fj_bin_pf_dist]], 1, 3); + __builtin_prefetch(&rows_p[rcon_p[ii + fj_bin_pf_dist]], 1, 3); - const int32_t r = pb.reverse_constraints[ii]; - fj_bin_row_t& h = rows[r]; - const coef_t kv = pb.reverse_coefficients[ii]; + const int32_t r = rcon_p[ii]; + fj_bin_row_t& h = rows_p[r]; + const coef_t kv = rcoef_p[ii]; const int32_t old_lhs = h.lhs; const int32_t new_lhs = old_lhs + (int32_t)kv * delta; const int32_t s = h.sign; @@ -730,18 +747,18 @@ struct fj_bin_engine_t : fj_binary_state_t { const bool deep_sat = old_slack > margin && new_slack > margin; const bool deep_viol = old_slack < -margin && new_slack < -margin; if (!(deep_sat || deep_viol)) { - const int32_t kb = pb.offsets[r], ke = pb.offsets[r + 1]; + const int32_t kb = offsets_p[r], ke = offsets_p[r + 1]; // The offsets are already loaded for the call, so the width choice is a compare rather // than a stored per-row flag. // TODO: check that this may not cause AVX512 powerdown overheads if the AVX2 row/AVX512 row ratio is unbalanced fj_bin_patch_row(fj_bin_patch_width_for(ke - kb, narrow4_max, narrow8_max), - pb.variables.data(), - pb.coefficients.data(), + vars_p, + coefs_p, kb, ke, - var_score.data(), - nnz_score_delta.data(), - assign_i32.data(), + var_score_p, + nnz_delta_p, + assign_p, s, h.weight, new_slack, @@ -755,7 +772,7 @@ struct fj_bin_engine_t : fj_binary_state_t { if (!deep_sat) { const int32_t pv = score_delta(h, new_lhs, new_flip, kv); own_score += pv; - nnz_score_delta[pb.reverse_to_csr[ii]] = pv; + nnz_delta_p[rcsr_p[ii]] = pv; } h.lhs = new_lhs; } @@ -779,7 +796,6 @@ struct fj_bin_engine_t : fj_binary_state_t { const int32_t tenure = tabu_tenure_min + (int32_t)(rng.next_u32() % (uint32_t)(tabu_tenure_max - tabu_tenure_min)); tabu.on_flip(var, iters, tenure); - std::fill(var_bitmap.begin(), var_bitmap.end(), (char)0); } // Publish a new best into the climber, which owns the reporting contract. @@ -896,6 +912,11 @@ struct fj_bin_engine_t : fj_binary_state_t { } } } + // Restore the all-zero invariant by revisiting only what was set: the sampled rows hold a few + // dozen variables against n in the thousands, so this is far cheaper than clearing the array. + for (int32_t r : target_rows) { + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) var_bitmap[pb.variables[k]] = 0; + } return {best_v, best_s}; } From f334465014e1c33046492c010da9eb36458e7c4e Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Thu, 6 Aug 2026 12:10:22 -0700 Subject: [PATCH 21/78] perf(cpufj-bin): vectorize apply_move's row walk supportcase22 goes from 3.598M to 4.348M aggregate iterations/s, +20.6%, with the search trajectory bit-identical. 85% of row visits leave their row deeply satisfied on both sides of the flip and do nothing but advance its slack. That part is uniform and now runs in Highway: fj_bin_walk_rows advances a tile of a variable's incidences and returns the ones that need scalar attention, which the caller finishes. The layout is what makes it cheap. Rows now store sign * (bound - lhs) rather than lhs, in a standalone row_slack, so the update is new_slack = old_slack - signed_coefficient[i] * delta and neither bound nor lhs appears. signed_coefficient and incident_row_cmax are replicated per incidence, so the kernel reads them at unit stride and the only irregular access left is row_slack itself: one gather and one scatter per vector, against four gathers and a scatter for a literal SoA split of the row record. That also empties fj_bin_row_t down to weight and sign. Only deep_sat is tested in the kernel. deep_viol stays with the caller because it fires on 0.02% of visits but guards the widest rows in the matrix -- deleting it outright cost 1.0% on supportcase22, since those rows average ~2,100 nonzeros and it was suppressing 16% of all patch work. The caller tiles rather than interleaving its tail into the vector loop: the lane loop sits behind a dispatch pointer that cannot be inlined, while the tail needs engine state the kernels TU cannot reach, so tiling is what keeps the patch calls outside it without a per-group callback. Trajectory verified by diffing the per-climber (iteration, viol, best, maxw) traces: identical multisets across all 16 climbers to 3,000,000 iterations each, 48,016 log points. A scalar arm for short incidence ranges was tried and dropped. Sweeping the degree below which apply_move walked the rows itself, bnatt400 degraded monotonically 14.43M -> 14.19M as the threshold went 0 -> 64, and crypt16 and supportcase22 were flat. bnatt400 (-1.1%) and crypt16 (-3.1%) do regress, but not for that reason, and the cause is still open. Co-Authored-By: Claude Opus 5 --- .../feasibility_jump/fj_cpu_binary.cu | 191 ++++++++++-------- .../feasibility_jump/fj_cpu_binary.cuh | 52 +++++ .../fj_cpu_binary_kernels.cpp | 142 +++++++++++++ 3 files changed, 305 insertions(+), 80 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 5b2ffae2eb..48aac555e8 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -162,15 +162,17 @@ struct fj_bin_tabu_t { } }; -// One row of the narrowed problem. bound/sign encode the single finite bound the split leaves: -// sign +1 for lhs <= bound, -1 for lhs >= bound. cmax is max|coef| over the row, which bounds how -// far a single flip can move the slack. +// What the apply path still needs per row once the row walk is vectorized: everything else it used +// to read from here now reaches it at unit stride. +// +// The mutable state moved out to the engine's row_slack, which holds sign * (bound - lhs) rather +// than lhs, because that is what turns the walk's update into a subtraction and lets it gather one +// array instead of four. bound went with it -- only the rebuild paths need it, and they read +// pb.bound. cmax went to pb.incident_row_cmax, replicated per incidence. sign stays because +// fj_bin_patch_row still takes it, and weight because it is genuinely mutable. template struct fj_bin_row_t { - int32_t lhs; int32_t weight; - int32_t bound; - coef_t cmax; int8_t sign; }; @@ -190,6 +192,12 @@ struct fj_bin_problem_t { std::vector reverse_coefficients; std::vector reverse_to_csr; + // Per incidence, for the vectorized row walk: sign * coefficient folded once, and the row's cmax + // replicated. Both are structural. Indexed like the transpose above, so the walk reads them at + // unit stride instead of gathering sign, coefficient and cmax per row. + std::vector signed_coefficient; + std::vector incident_row_cmax; + std::vector bound; std::vector sign; std::vector cmax; @@ -432,6 +440,8 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, pb.reverse_constraints.resize(pb.nnz); pb.reverse_coefficients.resize(pb.nnz); pb.reverse_to_csr.resize(pb.nnz); + pb.signed_coefficient.resize(pb.nnz); + pb.incident_row_cmax.resize(pb.nnz); { std::vector cursor(pb.reverse_offsets.begin(), pb.reverse_offsets.begin() + n); for (int32_t r = 0; r < n_split; ++r) { @@ -440,11 +450,20 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, pb.reverse_constraints[slot] = r; pb.reverse_coefficients[slot] = pb.coefficients[k]; pb.reverse_to_csr[slot] = k; + // The scan admits int8 only up to |coef| 127 and int16 only up to 32767, so negating a + // coefficient cannot overflow its own width. + pb.signed_coefficient[slot] = (coef_t)(pb.sign[r] * pb.coefficients[k]); + pb.incident_row_cmax[slot] = pb.cmax[r]; } } } - // Lookahead room for the row-walk prefetch. Reads land on row 0, which is prefetched harmlessly. - pb.reverse_constraints.resize(pb.nnz + fj_bin_pf_dist, 0); + // Lookahead room for the row walk: a vector of overhang for the kernel's unit-stride loads, and + // the prefetch distance the scalar path uses. Reads land on row 0, harmlessly, and every lane past + // a variable's range is masked out of the gather, the scatter and the compress. + const int32_t rpad = fj_bin_pf_dist > pad ? fj_bin_pf_dist : pad; + pb.reverse_constraints.resize(pb.nnz + rpad, 0); + pb.signed_coefficient.resize(pb.nnz + rpad, (coef_t)0); + pb.incident_row_cmax.resize(pb.nnz + rpad, (coef_t)1); pb.objective.resize(n); for (int32_t v = 0; v < n; ++v) { @@ -462,6 +481,10 @@ struct fj_bin_engine_t : fj_binary_state_t { fj_bin_problem_t pb; std::vector> rows; + // Per row, sign * (bound - lhs): negative exactly when the row is violated, and moved by a flip + // by exactly -signed_coefficient. The only mutable state the vectorized walk gathers. + std::vector row_slack; + std::vector assign; std::vector best_assign; std::vector seed_assign; // restart target @@ -509,6 +532,7 @@ struct fj_bin_engine_t : fj_binary_state_t { int32_t narrow4_max{0}; int32_t narrow8_max{0}; + // Settings read at solve entry, where the climber carries populated values. int32_t seed{0}; int32_t tabu_tenure_min{3}; @@ -593,9 +617,8 @@ struct fj_bin_engine_t : fj_binary_state_t { int32_t agg_base = 0, agg_bonus = 0; for (int32_t i = pb.reverse_offsets[v]; i < pb.reverse_offsets[v + 1]; ++i) { const fj_bin_row_t& h = rows[pb.reverse_constraints[i]]; - const int32_t s = h.sign; - const int32_t os = s * (h.bound - h.lhs); - const int32_t ns = os - s * ((int32_t)pb.reverse_coefficients[i] * flip); + const int32_t os = row_slack[pb.reverse_constraints[i]]; + const int32_t ns = os - (int32_t)pb.signed_coefficient[i] * flip; int32_t base = 0, bonus = 0; fj_bin_score_delta_parts(os, ns, h.weight, base, bonus); agg_base += base; @@ -632,25 +655,18 @@ struct fj_bin_engine_t : fj_binary_state_t { } } - // Branchless score delta of flipping a variable, as seen by one row. base is the weighted change - // in satisfaction; bonus is the weighted change in strict slack. When both states are violated - // the improving direction earns half weight, matching excess_improvement_weight of 1/2. - int32_t score_delta(const fj_bin_row_t& h, int32_t lhs, int8_t delta, coef_t k) const - { - const int32_t s = h.sign; - const int32_t os = s * (h.bound - lhs); - const int32_t ns = os - s * ((int32_t)k * delta); - return fj_bin_packed_score_delta(os, ns, h.weight); - } - void rebuild_scores() { std::fill(var_score.begin(), var_score.end(), 0); for (int32_t r = 0; r < pb.n_constraints; ++r) { - const fj_bin_row_t& h = rows[r]; + const int32_t weight = rows[r].weight; + const int32_t sign = rows[r].sign; + const int32_t os = row_slack[r]; for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { - const int32_t v = pb.variables[k]; - const int32_t p = score_delta(h, h.lhs, (int8_t)(1 - 2 * assign[v]), pb.coefficients[k]); + const int32_t v = pb.variables[k]; + const int32_t flip = 1 - 2 * assign[v]; + const int32_t ns = os - sign * ((int32_t)pb.coefficients[k] * flip); + const int32_t p = fj_bin_packed_score_delta(os, ns, weight); nnz_score_delta[k] = p; var_score[v] += p; } @@ -658,7 +674,7 @@ struct fj_bin_engine_t : fj_binary_state_t { nnz_touched += pb.nnz; } - void recompute_lhs() + void recompute_slack() { violated_list.clear(); std::fill(is_violated.begin(), is_violated.end(), (uint8_t)0); @@ -666,8 +682,9 @@ struct fj_bin_engine_t : fj_binary_state_t { int32_t lhs = 0; for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) lhs += (int32_t)pb.coefficients[k] * assign[pb.variables[k]]; - rows[r].lhs = lhs; - if (rows[r].sign * (rows[r].bound - lhs) < 0) set_violated(r); + const int32_t slack = pb.sign[r] * (pb.bound[r] - lhs); + row_slack[r] = slack; + if (slack < 0) set_violated(r); } incumbent_objective = 0; for (int32_t v = 0; v < pb.n_variables; ++v) incumbent_objective += pb.objective[v] * assign[v]; @@ -706,13 +723,14 @@ struct fj_bin_engine_t : fj_binary_state_t { const int32_t prev_violated = (int32_t)violated_list.size(); int32_t own_score = 0; - // The loop writes a row's lhs through fj_bin_row_t* and a score delta through int32_t*, either - // of which may alias a vector's internal pointer as far as the compiler can prove. Without - // these locals it reloads every base pointer below out of `this` on each row visit, and cannot - // turn the index walks into pointer inductions. + // The tail writes a score delta through int32_t* and calls out to the patch, either of which may + // alias a vector's internal pointer as far as the compiler can prove. Without these locals it + // reloads every base pointer below out of `this` on each visit. fj_bin_row_t* const rows_p = rows.data(); + int32_t* const slack_p = row_slack.data(); const int32_t* const rcon_p = pb.reverse_constraints.data(); - const coef_t* const rcoef_p = pb.reverse_coefficients.data(); + const coef_t* const skv_p = pb.signed_coefficient.data(); + const coef_t* const rcmax_p = pb.incident_row_cmax.data(); const int32_t* const rcsr_p = pb.reverse_to_csr.data(); const int32_t* const offsets_p = pb.offsets.data(); const int32_t* const vars_p = pb.variables.data(); @@ -721,60 +739,72 @@ struct fj_bin_engine_t : fj_binary_state_t { int32_t* const nnz_delta_p = nnz_score_delta.data(); const int32_t* const assign_p = assign_i32.data(); - for (int32_t ii = ob; ii < oe; ++ii) { - // Write hint: the row's lhs is updated at the end of every iteration, so the line is wanted - // exclusive. The padding on reverse_constraints makes the lookahead unconditional. - __builtin_prefetch(&rows_p[rcon_p[ii + fj_bin_pf_dist]], 1, 3); - + // Everything a visit still needs once its slack has been advanced. Shared by the two arms below + // so the walk's shape is the only thing that differs between them. + auto finish = [&](int32_t ii) { const int32_t r = rcon_p[ii]; fj_bin_row_t& h = rows_p[r]; - const coef_t kv = rcoef_p[ii]; - const int32_t old_lhs = h.lhs; - const int32_t new_lhs = old_lhs + (int32_t)kv * delta; - const int32_t s = h.sign; - const int32_t old_slack = s * (h.bound - old_lhs); - const int32_t new_slack = s * (h.bound - new_lhs); + const int32_t skv = (int32_t)skv_p[ii]; + const int32_t new_slack = slack_p[r]; + const int32_t old_slack = new_slack + skv * delta; + // A row can only cross its boundary if the flip moves it by at least the distance to it, so + // every transition is inside this list and none was lost with the rows the walk absorbed. if (new_slack < 0 && old_slack >= 0) { set_violated(r); } else if (new_slack >= 0 && old_slack < 0) { set_satisfied(r); } - // A row that stays clear of its boundary by more than max|coef| on both sides cannot change - // any variable's satisfaction flags, so its patch is skipped entirely. - const int32_t margin = h.cmax; - const bool deep_sat = old_slack > margin && new_slack > margin; - const bool deep_viol = old_slack < -margin && new_slack < -margin; - if (!(deep_sat || deep_viol)) { - const int32_t kb = offsets_p[r], ke = offsets_p[r + 1]; - // The offsets are already loaded for the call, so the width choice is a compare rather - // than a stored per-row flag. - // TODO: check that this may not cause AVX512 powerdown overheads if the AVX2 row/AVX512 row ratio is unbalanced - fj_bin_patch_row(fj_bin_patch_width_for(ke - kb, narrow4_max, narrow8_max), - vars_p, - coefs_p, - kb, - ke, - var_score_p, - nnz_delta_p, - assign_p, - s, - h.weight, - new_slack, - var); + // The mirror of the walk's deep_sat test. Kept here rather than there because it fires on + // 0.02% of visits and guards the widest rows in the matrix: measured, moving it into the + // vector loop costs more in the 85% case than it saves in the 0.02% one. + const int32_t margin = (int32_t)rcmax_p[ii]; + if (!(old_slack < -margin && new_slack < -margin)) { + const int32_t kb = offsets_p[r], ke = offsets_p[r + 1]; + // The offsets are already loaded for the call, so the width choice is a compare rather + // than a stored per-row flag. + // TODO: check that this may not cause AVX512 powerdown overheads if the AVX2 row/AVX512 row ratio is unbalanced + fj_bin_patch_row(fj_bin_patch_width_for(ke - kb, narrow4_max, narrow8_max), + vars_p, + coefs_p, + kb, + ke, + var_score_p, + nnz_delta_p, + assign_p, + h.sign, + h.weight, + new_slack, + var); nnz_touched += ke - kb; nnz_patched += ke - kb; } - // The flipped variable's own score delta is provably zero when the row is deeply satisfied - // both ways, and already stored as zero there. - if (!deep_sat) { - const int32_t pv = score_delta(h, new_lhs, new_flip, kv); - own_score += pv; - nnz_delta_p[rcsr_p[ii]] = pv; - } - h.lhs = new_lhs; + // The flipped variable's own score delta. Zero on the rows the walk absorbed -- deeply + // satisfied both ways -- and already stored as zero there. + const int32_t pv = fj_bin_packed_score_delta(new_slack, new_slack - skv * new_flip, h.weight); + own_score += pv; + nnz_delta_p[rcsr_p[ii]] = pv; + }; + + // A tile at a time: the kernel advances every slack in the tile and reports back only the visits + // that left the row within reach of its boundary, which on supportcase22 is 15.1% of them. The + // buffer is a stack array rather than one sized to the widest reverse degree because the tail + // runs between tiles, which is also what keeps the patch calls out of the vector loop. + // + // Unconditional: a scalar arm for short ranges was tried and never won. Sweeping the degree + // below which apply_move walked the rows itself, bnatt400 degraded monotonically from 14.43M to + // 14.19M iterations/s as the threshold rose from 0 to 64, and crypt16 and supportcase22 were + // flat. At a median degree of 13 and 7 respectively, one gather still beats that many dependent + // scalar load-modify-stores, because it breaks the dependence chain through row_slack rather + // than following it. + int32_t tile_incidence[fj_bin_walk_tile]; + for (int32_t t0 = ob; t0 < oe; t0 += fj_bin_walk_tile) { + const int32_t t1 = (t0 + fj_bin_walk_tile < oe) ? t0 + fj_bin_walk_tile : oe; + const int32_t n_tail = + fj_bin_walk_rows(slack_p, rcon_p, skv_p, rcmax_p, t0, t1, delta, tile_incidence); + for (int32_t j = 0; j < n_tail; ++j) finish(tile_incidence[j]); } nnz_touched += oe - ob; rows_walked += oe - ob; @@ -822,7 +852,7 @@ struct fj_bin_engine_t : fj_binary_state_t { if (new_weight == h.weight) return; h.weight = new_weight; if (new_weight > max_weight) max_weight = new_weight; - // lhs is unchanged here, and no variable is excluded, so skip_var matches no index. + // The slack is unchanged here, and no variable is excluded, so skip_var matches no index. const int32_t kb = pb.offsets[r], ke = pb.offsets[r + 1]; fj_bin_patch_row(fj_bin_patch_width_for(ke - kb, narrow4_max, narrow8_max), pb.variables.data(), @@ -834,7 +864,7 @@ struct fj_bin_engine_t : fj_binary_state_t { assign_i32.data(), h.sign, h.weight, - h.sign * (h.bound - h.lhs), + row_slack[r], -1); nnz_touched += ke - kb; nnz_patched += ke - kb; @@ -990,7 +1020,7 @@ struct fj_bin_engine_t : fj_binary_state_t { assign[v] = (int8_t)(rng.next_u32() & 1u); assign_i32[v] = assign[v]; } - recompute_lhs(); + recompute_slack(); } // Restart returns the assignment to the seed the climber was constructed with, leaving the @@ -1003,7 +1033,7 @@ struct fj_bin_engine_t : fj_binary_state_t { max_weight = fj_bin_ddfw_init; objective_weight = 0; tabu.clear(iters); - recompute_lhs(); + recompute_slack(); last_restart_iter = iters; last_feasible_entrance_iter = iters; } @@ -1045,7 +1075,8 @@ struct fj_bin_engine_t : fj_binary_state_t { rows.resize(m); for (int32_t r = 0; r < m; ++r) - rows[r] = fj_bin_row_t{0, pb.initial_weight[r], pb.bound[r], pb.cmax[r], pb.sign[r]}; + rows[r] = fj_bin_row_t{pb.initial_weight[r], pb.sign[r]}; + row_slack.assign(m, 0); var_score.assign(n, 0); nnz_score_delta.assign(pb.nnz + fj_bin_simd_padding(), 0); @@ -1062,7 +1093,7 @@ struct fj_bin_engine_t : fj_binary_state_t { feasible_found = false; iters = 0; last_restart_iter = 0; - recompute_lhs(); + recompute_slack(); } void solve(fj_cpu_climber_t& climber, f_t time_limit, double work_unit_limit) override diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh index 6fcb53eea5..d6daf9c160 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh @@ -155,6 +155,58 @@ void fj_bin_patch_row(fj_bin_patch_width_t width, int32_t os_new, int32_t skip_var); +// Incidences handed to fj_bin_walk_rows per call, and so the size of the caller's output buffer. +// Large enough that the dynamic dispatch and the call's setup amortize away -- a variable of +// supportcase22 averages 344 incidences, so one or two calls per move -- and small enough that the +// buffer is a stack array and its touched part stays in L1. A multiple of every supported lane +// count, so only a variable's last tile has a masked remainder. +constexpr int32_t fj_bin_walk_tile = 256; + +// Advance every row incident to one flipped variable, and report which of those visits the caller +// must finish by hand. +// +// An "incidence" is one (variable, row) pair of the matrix, so the reverse CSR indexed by a +// variable's [incidence_begin, incidence_end) names the rows that variable appears in. Three arrays +// are read at that index, all at unit stride: +// +// incident_row[i] the row this incidence touches +// signed_coefficient[i] sign * coefficient, folded once at build time +// incident_row_cmax[i] that row's max|coef|, replicated here so it need not be gathered +// +// The last two are structural and fixed for the life of the solve. row_slack[r] is indexed by row, +// and holds sign * (bound - lhs) -- the signed slack the engine stores in place of lhs, which is +// what reduces the update to a subtraction. delta is the flip direction, +1 or -1, uniform over the +// call. +// +// For every incidence i in the range this applies +// row_slack[incident_row[i]] -= signed_coefficient[i] * delta +// then writes to out_incidence, in increasing order, the subset of i whose row is not deeply +// satisfied on both sides of the flip -- 15.1% of visits on supportcase22 -- and returns how many. +// Everything those visits still need is indirect (the row's weight, the own-score delta, the +// violated-set transitions, the patch) and stays with the caller. +// +// The caller drives this a tile at a time, running each tile's tail before asking for the next, so +// out_incidence is a fj_bin_walk_tile-sized buffer the caller can keep on its stack rather than one +// sized to the widest reverse degree in the matrix. Tiling this way rather than interleaving the +// tail into the vector loop keeps every patch call outside it: the loop over lanes lives here, +// behind a dynamic dispatch pointer that cannot be inlined, while the tail needs engine-side state +// this translation unit cannot reach. A tile still runs the vector body many times before yielding, +// which is what lets consecutive gathers overlap. +// +// out_incidence must hold incidence_end - incidence_begin entries. Reads up to +// fj_bin_simd_padding() elements past incidence_end from the three per-incidence arrays. Interior +// tiles read into the next tile, which is in bounds; the final one reads past the variable's range, +// so those arrays carry that padding. +template +int32_t fj_bin_walk_rows(int32_t* row_slack, + const int32_t* incident_row, + const coef_t* signed_coefficient, + const coef_t* incident_row_cmax, + int32_t incidence_begin, + int32_t incidence_end, + int32_t delta, + int32_t* out_incidence); + // Argmax over var_score, scanning all n variables. Valid while the objective weight is zero, where // the full score is exactly var_score. Yields best_var of -1 only if n is 0. // diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp index 64541a8777..90affda3fb 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp @@ -43,6 +43,108 @@ int32_t PaddingImpl() return k_mask_remainder ? (int32_t)hn::Lanes(hn::ScalableTag()) : 0; } +// Whether the row walk below is worth vectorizing on this target. It needs a real gather to read the +// slacks and a real compress to emit the tail list; where either is emulated the emulation costs +// more than the scalar loop it replaces, since 85% of visits do nothing but subtract and compare. +// The scalar arm still returns the same list, so the caller needs no second code path -- it pays +// only one store per reported visit. +constexpr bool k_vector_walk = + (HWY_TARGET <= HWY_AVX3) || HWY_TARGET_IS_SVE || (HWY_TARGET == HWY_RVV); + +// One tile of a flipped variable's incidence range, vectorized. The caller tiles the range and runs +// each tile's tail before asking for the next; see fj_bin_walk_tile. +// +// Measured on supportcase22: 84.87% of row visits leave the row deeply satisfied on both sides of +// the flip, and those visits do nothing but update the slack. The remaining 15.13% need the row's +// weight, the flipped variable's own score delta, the violated-set transitions and usually a +// patch -- all indirect, all awkward in a vector. So this kernel does only the uniform part and +// hands back the indices of the visits that are not deep_sat, in increasing order, for the caller +// to finish scalar. +// +// The layout this assumes is what makes it worth doing. Storing the row's signed slack rather than +// its lhs collapses the update to +// +// new_slack = old_slack - sign * coef * delta = old_slack - skv[ii] * delta +// +// so bound and lhs never appear, and sign and coef fold into one per-incidence constant. skv and +// cmax are replicated per incidence, which makes them unit-stride loads. What remains irregular is +// the slack itself: one gather and one scatter per vector, against four gathers and a scatter for a +// literal SoA split of the row record. +// +// Trajectory is preserved exactly. The slack update is per row and order-independent; the caller's +// tail visits its indices in the same order the scalar loop did; and a deep_sat row is never read by +// the tail, so updating it early is not observable. +template +int32_t WalkRowsImpl(int32_t* HWY_RESTRICT row_slack, + const int32_t* HWY_RESTRICT incident_row, + const coef_t* HWY_RESTRICT signed_coefficient, + const coef_t* HWY_RESTRICT incident_row_cmax, + int32_t incidence_begin, + int32_t incidence_end, + int32_t delta, + int32_t* HWY_RESTRICT out_incidence) +{ + int32_t n_out = 0; + int32_t ii = incidence_begin; + + if constexpr (k_vector_walk) { + const hn::ScalableTag d; + const hn::Rebind dc; // same lane count, narrower lanes + using V = hn::Vec; + const size_t N = hn::Lanes(d); + + const V vdelta = hn::Set(d, delta); + + // The unit-stride loads always run whole and read into the per-incidence padding; FirstN keeps + // the overhang out of the gather, the scatter and the compress. + for (; ii < incidence_end; ii += (int32_t)N) { + const auto active = hn::FirstN(d, (size_t)(incidence_end - ii)); + + const V rows = hn::LoadU(d, incident_row + ii); + const V skv = hn::PromoteTo(d, hn::LoadU(dc, signed_coefficient + ii)); + const V cmax = hn::PromoteTo(d, hn::LoadU(dc, incident_row_cmax + ii)); + + const V os = hn::MaskedGatherIndex(active, d, row_slack, rows); + const V ns = hn::Sub(os, hn::Mul(skv, vdelta)); + + // Only the satisfied side. deep_viol is the caller's business: it fires on 0.02% of visits but + // guards the widest rows in the matrix, so it belongs where the row length is already known. + const auto deep_sat = hn::And(hn::Gt(os, cmax), hn::Gt(ns, cmax)); + const auto to_tail = hn::AndNot(deep_sat, active); + +#if HWY_TARGET == HWY_AVX3_ZEN4 + // Same Zen 4 microcode argument as the score scatter in PatchRowBody: VPSCATTERDD is 89 uops + // at ~24 CPI, against two vector stores and N scalar stores here. Unlike that one this is a + // pure store with no read-modify-write, so it needs its own A/B before the arm is settled. + HWY_ALIGN int32_t row_lane[hn::MaxLanes(d)], slack_lane[hn::MaxLanes(d)]; + hn::Store(rows, d, row_lane); + hn::Store(ns, d, slack_lane); + const size_t lanes = HWY_MIN(N, (size_t)(incidence_end - ii)); + for (size_t i = 0; i < lanes; ++i) row_slack[row_lane[i]] = slack_lane[i]; +#else + hn::MaskedScatterIndex(ns, active, d, row_slack, rows); +#endif + + // A variable meets each row at most once, so no two lanes carry the same row and neither the + // scatter above nor the store loop needs conflict detection. + n_out += (int32_t)hn::CompressStore(hn::Iota(d, ii), to_tail, d, out_incidence + n_out); + } + return n_out; + } + + // Targets without a native gather or compress. Also the remainder is not reached here: the loop + // above runs to oe under FirstN, and this arm replaces it wholesale rather than tailing it. + for (; ii < incidence_end; ++ii) { + const int32_t row = incident_row[ii]; + const int32_t os = row_slack[row]; + const int32_t ns = os - (int32_t)signed_coefficient[ii] * delta; + row_slack[row] = ns; + const int32_t cmax = (int32_t)incident_row_cmax[ii]; + if (!(os > cmax && ns > cmax)) out_incidence[n_out++] = ii; + } + return n_out; +} + // Row remainder when it is peeled rather than masked, and the whole row on scalar targets. template void PatchRowScalar(const int32_t* HWY_RESTRICT variables, @@ -312,6 +414,8 @@ namespace cuopt::mathematical_optimization::mip { HWY_EXPORT_T(PatchRowNatI8, PatchRowImpl); HWY_EXPORT_T(PatchRowN8I8, PatchRowNarrow8Impl); HWY_EXPORT_T(PatchRowN4I8, PatchRowNarrow4Impl); +HWY_EXPORT_T(WalkRowsI8, WalkRowsImpl); +HWY_EXPORT_T(WalkRowsI16, WalkRowsImpl); HWY_EXPORT_T(PatchRowNatI16, PatchRowImpl); HWY_EXPORT_T(PatchRowN8I16, PatchRowNarrow8Impl); HWY_EXPORT_T(PatchRowN4I16, PatchRowNarrow4Impl); @@ -368,6 +472,18 @@ static const fj_bin_patch_fn_t fj_bin_patch_i16[3] = { static const fj_bin_patch_fn_t* fj_bin_patch_table(int8_t) { return fj_bin_patch_i8; } static const fj_bin_patch_fn_t* fj_bin_patch_table(int16_t) { return fj_bin_patch_i16; } +template +using fj_bin_walk_fn_t = int32_t (*)( + int32_t*, const int32_t*, const coef_t*, const coef_t*, int32_t, int32_t, int32_t, int32_t*); + +static const auto fj_bin_walk_i8 = + (fj_bin_choose_target(), (fj_bin_walk_fn_t)HWY_DYNAMIC_POINTER_T(WalkRowsI8)); +static const auto fj_bin_walk_i16 = + (fj_bin_choose_target(), (fj_bin_walk_fn_t)HWY_DYNAMIC_POINTER_T(WalkRowsI16)); + +static fj_bin_walk_fn_t fj_bin_walk_fn(int8_t) { return fj_bin_walk_i8; } +static fj_bin_walk_fn_t fj_bin_walk_fn(int16_t) { return fj_bin_walk_i16; } + static const auto fj_bin_argmax_fn = (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(ArgmaxImpl)); static const auto fj_bin_padding_fn = (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(PaddingImpl)); static const auto fj_bin_narrow4_max_fn = @@ -376,6 +492,32 @@ static const auto fj_bin_narrow8_max_fn = (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(Narrow8MaxImpl)); int32_t fj_bin_simd_padding() { return fj_bin_padding_fn(); } + +template +int32_t fj_bin_walk_rows(int32_t* row_slack, + const int32_t* incident_row, + const coef_t* signed_coefficient, + const coef_t* incident_row_cmax, + int32_t incidence_begin, + int32_t incidence_end, + int32_t delta, + int32_t* out_incidence) +{ + return fj_bin_walk_fn(coef_t{})(row_slack, + incident_row, + signed_coefficient, + incident_row_cmax, + incidence_begin, + incidence_end, + delta, + out_incidence); +} + +template int32_t fj_bin_walk_rows( + int32_t*, const int32_t*, const int8_t*, const int8_t*, int32_t, int32_t, int32_t, int32_t*); +template int32_t fj_bin_walk_rows( + int32_t*, const int32_t*, const int16_t*, const int16_t*, int32_t, int32_t, int32_t, int32_t*); + int32_t fj_bin_simd_narrow4_max() { return fj_bin_narrow4_max_fn(); } int32_t fj_bin_simd_narrow8_max() { return fj_bin_narrow8_max_fn(); } From dd1501d3e289440e72c2d1bcedebd0520c23e968 Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Fri, 7 Aug 2026 05:12:21 -0700 Subject: [PATCH 22/78] docs: record the Highway third-party licence The binary fast path's kernels are built with Google Highway, fetched at configure time like PSLP and PaPILO, but the dependency was never recorded in thirdparty/THIRD_PARTY_LICENSES. Highway is dual-licensed under Apache 2.0 or BSD 3-Clause; cuOpt elects Apache 2.0, so the entry names that licence and reproduces its text verbatim from the upstream LICENSE file. Highway ships no NOTICE file, so Apache 2.0 section 4(d) has nothing to propagate, and the sources are consumed unmodified, so 4(b) does not apply either. Signed-off-by: Alice Boucher --- thirdparty/THIRD_PARTY_LICENSES | 214 ++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/thirdparty/THIRD_PARTY_LICENSES b/thirdparty/THIRD_PARTY_LICENSES index 7424a65232..15d5a08e00 100644 --- a/thirdparty/THIRD_PARTY_LICENSES +++ b/thirdparty/THIRD_PARTY_LICENSES @@ -597,3 +597,217 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +----------------------------------------------------------------------------------------- + +== highway Apache-2.0 + +Files: cpp/build/_deps/highway-src + +Copyright (c) The Highway Project Authors. All rights reserved. + +Highway is dual-licensed under the Apache License 2.0 or the BSD 3-Clause License; +cuOpt elects the Apache License 2.0, reproduced below. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 57e2ef7712a24126bac13790a9b7a4fb52d9220e Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Fri, 7 Aug 2026 05:18:47 -0700 Subject: [PATCH 23/78] bit of cleanup --- .../linear_programming/cuopt/run_cpufj.cu | 11 ----------- cpp/CMakeLists.txt | 19 +------------------ cpp/src/utilities/version_info.cpp | 4 ---- 3 files changed, 1 insertion(+), 33 deletions(-) diff --git a/benchmarks/linear_programming/cuopt/run_cpufj.cu b/benchmarks/linear_programming/cuopt/run_cpufj.cu index 6844d09347..e62a8ef224 100644 --- a/benchmarks/linear_programming/cuopt/run_cpufj.cu +++ b/benchmarks/linear_programming/cuopt/run_cpufj.cu @@ -5,14 +5,6 @@ */ /* clang-format on */ -// Benchmark-only harness for the CPU feasibility-jump portfolio. Loads an instance, builds one -// climber per portfolio slot from a zero start clamped to variable bounds, and runs them in -// parallel on pinned cores for a fixed wall-clock budget. Reports per-climber crossing, objective -// and throughput. -// -// No presolve: pass an already-presolved instance. The climbers are built from problem_t, so the -// binary fast path is reachable when the instance qualifies. - #include #include #include @@ -27,14 +19,11 @@ #include #include -#include -#include #include #include #include #include #include -#include #include namespace { diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index d95f48f58b..0fe2bf064b 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -307,11 +307,6 @@ set(BUILD_SHARED_LIBS OFF) FetchContent_MakeAvailable(pslp) set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_SAVED}) - -# Highway - portable SIMD with runtime dispatch, used by the binary CPU FJ fast-path kernels -# https://github.com/google/highway -# contrib carries vqsort/image/math, none of which we use, and each would be compiled once per -# SIMD target. FetchContent_Declare( highway GIT_REPOSITORY "https://github.com/google/highway.git" @@ -326,7 +321,6 @@ set(HWY_ENABLE_EXAMPLES OFF CACHE BOOL "" FORCE) set(HWY_ENABLE_TESTS OFF CACHE BOOL "" FORCE) set(HWY_ENABLE_INSTALL OFF CACHE BOOL "" FORCE) -# Build Highway as static to embed in cuopt (mirrors PSLP above) set(BUILD_SHARED_LIBS_SAVED ${BUILD_SHARED_LIBS}) set(BUILD_SHARED_LIBS OFF) FetchContent_MakeAvailable(highway) @@ -717,8 +711,6 @@ target_include_directories(cuopt_objs target_link_libraries(cuopt_objs PRIVATE $) add_dependencies(cuopt_objs PSLP) -# Highway, linked by file for the same reason. Static and fully embedded into libcuopt.so; it is -# never installed (HWY_ENABLE_INSTALL OFF) and consumers of cuopt::cuopt never reference it. target_link_libraries(cuopt_objs PRIVATE $) add_dependencies(cuopt_objs hwy) @@ -1100,35 +1092,26 @@ if (BUILD_MIP_BENCHMARKS AND NOT BUILD_LP_ONLY) "${CMAKE_CURRENT_SOURCE_DIR}/src" ) - # CPU FJ portfolio benchmark. A .cu because it drives problem_t/solution_t directly and - # clamp_within_var_bounds instantiates a device lambda in this TU. + # CPU FJ standalone portfolio benchmark add_executable(solve_CPUFJ ../benchmarks/linear_programming/cuopt/run_cpufj.cu) - set_target_properties(solve_CPUFJ PROPERTIES CXX_SCAN_FOR_MODULES OFF) - - # -fopenmp for the CUDA TU as well: the internal headers this pulls in (omp_helpers.hpp, - # omp_atomic_t) are OMP-dependent, and OpenMP::OpenMP_CXX only covers CXX. target_compile_options(solve_CPUFJ PRIVATE "$<$:${CUOPT_CXX_FLAGS}>" "$<$:${CUOPT_CUDA_FLAGS}>" "$<$:-fopenmp>" ) - target_link_libraries(solve_CPUFJ PUBLIC cuopt OpenMP::OpenMP_CXX OpenMP::OpenMP_CUDA ) - target_include_directories(solve_CPUFJ PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src" "${papilo_SOURCE_DIR}/src" "${papilo_BINARY_DIR}" ) - - # Reached transitively: fj_cpu.cuh pulls branch_and_bound/symmetry.hpp, which needs dejavu. target_include_directories(solve_CPUFJ SYSTEM PRIVATE "${pslp_SOURCE_DIR}/include" "${dejavu_SOURCE_DIR}" diff --git a/cpp/src/utilities/version_info.cpp b/cpp/src/utilities/version_info.cpp index 2b1d3919d9..67ec9e6794 100644 --- a/cpp/src/utilities/version_info.cpp +++ b/cpp/src/utilities/version_info.cpp @@ -146,10 +146,6 @@ static std::string get_cpu_model() return "Unknown"; } -// The SIMD instruction set the vectorized CPU kernels actually dispatched to, which reflects the -// compiled target set as well as what this CPU supports. Highway names the AVX-512 family AVX3, -// with AVX3_DL / AVX3_ZEN4 / AVX3_SPR / AVX10_2 marking successively newer feature sets; those are -// reported under the name users know them by. static const char* get_simd_target() { const int64_t target = hwy::DispatchedTarget(); From febac7b83930d46c8d85ad6f1daba5c081309ce5 Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Fri, 7 Aug 2026 06:16:09 -0700 Subject: [PATCH 24/78] cleanup --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 11 +- .../feasibility_jump/fj_cpu.cuh | 16 -- .../feasibility_jump/fj_cpu_binary.cu | 202 +++++++----------- .../feasibility_jump/fj_cpu_binary.cuh | 139 ++---------- .../fj_cpu_binary_kernels.cpp | 55 +++-- 5 files changed, 125 insertions(+), 298 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index e1b7082b0d..da009c13a6 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1701,11 +1701,6 @@ void finalize_fj_cpu_host_initialization( // Precompute static problem features for regression model precompute_problem_features(fj_cpu); - - // Binary fast path. Depends only on the host problem mirrors and the incoming weights, both - // populated above; engine state is initialized later, at solve entry. Climbers built from a - // host LP reach here too and are declined by the predicate at their first slack column. - try_build_binary_fastpath(fj_cpu); } template @@ -1899,10 +1894,8 @@ std::unique_ptr> fj_t::create_cpu_climber( template void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double work_unit_limit) { - if (fj_cpu->binary_fast) { - fj_cpu->binary_fast->solve(*fj_cpu, in_time_limit, work_unit_limit); - return; - } + // problem fits the binary fastpath shape? run it (engine is solve-local) + if (try_cpufj_binary_solve(*fj_cpu, in_time_limit, work_unit_limit)) return; i_t local_mins = 0; auto loop_start = std::chrono::high_resolution_clock::now(); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index ce1d010151..bb528f54ea 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -22,17 +22,6 @@ namespace cuopt::mathematical_optimization::mip { -// Binary fast-path state. Defined in fj_cpu_binary.cuh, which only fj_cpu.cu includes, so this -// header stays independent of the fast path. The deleter is defined out of line for the same -// reason, mirroring fj_cpu_worker_t::fj_cpu_deleter_t. -template -struct fj_binary_state_t; - -template -struct fj_binary_state_deleter_t { - void operator()(fj_binary_state_t* ptr) const; -}; - template class probing_cache_t; @@ -227,11 +216,6 @@ struct fj_cpu_climber_t { instrumentation_aggregator_t memory_aggregator; // TODO atomic ref? c++20 std::atomic& preemption_flag; - - // Populated by try_build_binary_fastpath when the instance is all-binary with integer - // coefficients in int8/int16 range. Empty on every other instance, in which case cpufj_solve - // runs the general loop below. - std::unique_ptr, fj_binary_state_deleter_t> binary_fast; }; template diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 48aac555e8..a63d3ad802 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -15,12 +15,10 @@ #include -#include #include #include #include #include -#include #include namespace cuopt::mathematical_optimization::mip { @@ -41,31 +39,25 @@ const char* fj_binary_reject_name(fj_binary_reject_t reason) return "unknown"; } - -// Work-unit proxy: bytes attributed per nnz touched. Stands in for the byte counters the general -// path reads off its instrumented vectors. Calibrated by the owner. +// work unit proxy. will likely require a lot of tuning constexpr double fj_bin_bytes_per_nnz = 16.0; -// Tabu for binary variables. A binary variable's only move is a flip, so the direction is a -// function of the current assignment and the general four-array scheme collapses to two. -// flip_until is biased by a rolling base so it fits uint16. +// Tabu for binary variables, expressed as a ring buffer +// There can be at most max_tenure tabu'd variables at any given time. +// since max_tenure << n_vars, it's cheaper to maintain a ring buffer than a full array +// and it allows smaller instances to become L1 resident struct fj_bin_tabu_t { - static constexpr int32_t window = 65535 - 64; + static constexpr int32_t ring_size = 16; + static constexpr int32_t max_tenure = ring_size; + // Headroom so iter + tenure - iter_bias still fits uint16 when iter - iter_bias is at the rebase + // threshold. + static constexpr int32_t window = + (int32_t)std::numeric_limits::max() - max_tenure; std::vector flip_until; std::vector last_flip; - int32_t base{0}; - - // Ring of the last ring_size flips, indexed by iteration rather than by expiry. One variable - // flips per iteration, so each slot takes exactly one entry and nothing is ever displaced while - // still tabu; indexing by expiry instead would collide whenever two flips share a deadline, which - // a simulation over the [3,12] tenure range puts at 35% of insertions. - // - // This is what lets the global argmax stop reading flip_until. Tenure is bounded by the ring, so - // at most ring_size variables are tabu at once out of n: reading two bytes per variable to answer - // a question that is almost always no costs a third of that scan's traffic. On crypt16 it is the - // difference between a 31 KB working set that overflows a 32 KB L1 and a 21 KB one that does not. - static constexpr int32_t ring_size = 16; + int32_t iter_bias{0}; + int32_t ring_var[ring_size]; int32_t ring_expiry[ring_size]; @@ -75,7 +67,7 @@ struct fj_bin_tabu_t { flip_until.assign(n, 0); last_flip.assign(n, 0); clear_ring(); - base = 0; + iter_bias = 0; } void clear(int32_t iter) @@ -83,7 +75,7 @@ struct fj_bin_tabu_t { std::fill(flip_until.begin(), flip_until.end(), (uint16_t)0); std::fill(last_flip.begin(), last_flip.end(), 0); clear_ring(); - base = iter; + iter_bias = iter; } void clear_ring() @@ -96,15 +88,10 @@ struct fj_bin_tabu_t { void on_flip(int32_t v, int32_t iter, int32_t tenure) { - flip_until[v] = (uint16_t)(iter + tenure - base); + flip_until[v] = (uint16_t)(iter + tenure - iter_bias); last_flip[v] = iter; - // Drop any earlier entry for v before inserting the new one. flip_until keeps one deadline per - // variable and a reflip overwrites it, so without this the ring would hold a superseded, later - // deadline and block v past the point the per-variable test would have released it. A variable - // can be reflipped while still tabu: the local-minimum path tests the weaker - // iter == last_flip + 1. With this the ring holds at most one live entry per variable carrying - // its current deadline, which is exactly the invariant flip_until maintains. + // keep only one tabu entry per var for (int32_t i = 0; i < ring_size; ++i) { if (ring_var[i] == v) ring_var[i] = -1; } @@ -114,9 +101,7 @@ struct fj_bin_tabu_t { ring_expiry[slot] = iter + tenure; } - // Blocks every currently-tabu variable by writing the invalid sentinel over its score, and reports - // how many were touched. Paired with unblock around one argmax and nothing else: the score is - // maintained incrementally, so it may only be disturbed across a window in which no patch runs. + // replace the scores of tabu'd variable with sentinel values int32_t block_tabu(int32_t iter, int32_t* var_score, int32_t (&saved_var)[ring_size], @@ -135,8 +120,7 @@ struct fj_bin_tabu_t { return k; } - // Reverse order on purpose: a variable flipped twice inside the ring appears twice, and its second - // save holds the sentinel written by the first. Unwinding backwards restores the true score last. + // reverse the above operation. static void unblock_tabu(int32_t k, int32_t* var_score, const int32_t (&saved_var)[ring_size], @@ -148,17 +132,17 @@ struct fj_bin_tabu_t { bool blocked(int32_t v, int32_t iter, bool localmin) const { - return localmin ? (iter == last_flip[v] + 1) : ((uint16_t)(iter - base) < flip_until[v]); + return localmin ? (iter == last_flip[v] + 1) + : ((uint16_t)(iter - iter_bias) < flip_until[v]); } - // Rebase before iter - base can overflow the uint16 window. Expired entries saturate to 0, - // which reads as not-tabu. - void maybe_advance(int32_t iter) + // rebase the iteration bias value every 64k iter + void maybe_rebase(int32_t iter) { - if ((int64_t)iter - base <= window) return; - const uint16_t shift = (uint16_t)(iter - base); + if ((int64_t)iter - iter_bias <= window) return; + const uint16_t shift = (uint16_t)(iter - iter_bias); for (uint16_t& fu : flip_until) fu = (fu > shift) ? (uint16_t)(fu - shift) : (uint16_t)0; - base = iter; + iter_bias = iter; } }; @@ -223,19 +207,13 @@ constexpr int32_t fj_bin_ddfw_transfer = 1; constexpr int32_t fj_bin_ddfw_donor_samples = 1; constexpr int32_t fj_bin_restart_period = 5000000; -// `[study]` Prefetch distance for the reverse-CSR row walk, in rows. Each move visits the rows of -// one variable through reverse_constraints, whose entries are effectively random indices into a row -// array far larger than L2, and the sequence is data-dependent so no hardware prefetcher can follow -// it. reverse_constraints is padded by this much so the lookahead needs no bounds test. +// prefetch distance +// TODO: check if it actually matters at all for performance constexpr int32_t fj_bin_pf_dist = 8; -// Limits of the packed score. Breaching either corrupts the ordering, so compute_saturation -// reports the observed peaks against them at end of solve. constexpr int32_t fj_bin_base_limit = 1 << 16; constexpr int32_t fj_bin_bonus_limit = 1 << 14; -static inline bool fj_bin_is_integral(double v, double tol) { return std::fabs(v - std::round(v)) <= tol; } - static inline bool fj_bin_in_int32(double v) { return v >= (double)INT32_MIN && v <= (double)INT32_MAX; @@ -253,14 +231,13 @@ static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) return out; } - const double tol = c.view.pb.tolerances.integrality_tolerance; - const auto& var_bounds = c.h_var_bounds; - const auto& var_types = c.h_var_types; + const double tol = c.view.pb.tolerances.integrality_tolerance; + const auto& is_binary_variable = c.h_is_binary_variable; + cuopt_assert((int32_t)is_binary_variable.size() == n, "is_binary_variable size mismatch"); for (int32_t v = 0; v < n; ++v) { - auto bounds = var_bounds[v]; - if (var_types[v] != var_t::INTEGER || std::fabs(get_lower(bounds)) > tol || - std::fabs(get_upper(bounds) - 1.0) > tol) { + // Populated at climber init with integer_equal on [0,1] bounds. + if (!is_binary_variable[v]) { out.reject = fj_binary_reject_t::non_binary_var; out.bad_var = v; return out; @@ -277,7 +254,7 @@ static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) double row_abs_sum = 0; for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { const double a = coeffs[k]; - if (!fj_bin_is_integral(a, tol)) { + if (!is_integer(a, tol)) { out.reject = fj_binary_reject_t::fractional_coefficient; out.bad_row = r; return out; @@ -303,7 +280,7 @@ static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) const bool finite[2] = {lb_fin, ub_fin}; for (int s = 0; s < 2; ++s) { if (!finite[s]) continue; - if (!fj_bin_is_integral(sides[s], tol)) { + if (!is_integer(sides[s], tol)) { out.reject = fj_binary_reject_t::fractional_row_bound; out.bad_row = r; return out; @@ -372,7 +349,7 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { const double a = coeffs[k]; const long ai = std::lround(a); - if (!fj_bin_is_integral(a, tol) || ai < std::numeric_limits::min() || + if (!is_integer(a, tol) || ai < std::numeric_limits::min() || ai > std::numeric_limits::max()) { return false; } @@ -404,9 +381,8 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, // row without running off the end and can therefore mask its remainder rather than peeling it // into a scalar tail. The padding is never read as data: every lane past a row's end is excluded // from the gather, the scatter and the store by the row-length mask. - const int32_t pad = fj_bin_simd_padding(); - pb.variables.resize(pb.nnz + pad, 0); - pb.coefficients.resize(pb.nnz + pad, (coef_t)0); + pb.variables.resize(pb.nnz + fj_bin_simd_padding, 0); + pb.coefficients.resize(pb.nnz + fj_bin_simd_padding, (coef_t)0); // Scale the incoming weights into the DDFW band by one global factor, so relative structure // survives while every row clears the donation floor. Capped so the largest scaled weight stays @@ -460,7 +436,8 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, // Lookahead room for the row walk: a vector of overhang for the kernel's unit-stride loads, and // the prefetch distance the scalar path uses. Reads land on row 0, harmlessly, and every lane past // a variable's range is masked out of the gather, the scatter and the compress. - const int32_t rpad = fj_bin_pf_dist > pad ? fj_bin_pf_dist : pad; + const int32_t rpad = + fj_bin_pf_dist > fj_bin_simd_padding ? fj_bin_pf_dist : fj_bin_simd_padding; pb.reverse_constraints.resize(pb.nnz + rpad, 0); pb.signed_coefficient.resize(pb.nnz + rpad, (coef_t)0); pb.incident_row_cmax.resize(pb.nnz + rpad, (coef_t)1); @@ -477,7 +454,7 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, // The integer engine. Feasibility is an exact compare against one bound per row, so there is no // tolerance arithmetic and no compensated summation anywhere below. template -struct fj_bin_engine_t : fj_binary_state_t { +struct fj_bin_engine_t { fj_bin_problem_t pb; std::vector> rows; @@ -528,11 +505,6 @@ struct fj_bin_engine_t : fj_binary_state_t { // what bounds the index re-scan, so it is about the shape of the sweep and not cache capacity. int32_t argmax_tile{256}; - // Rows at or below these lengths go to the 4- and 8-lane patch kernels; 0 disables that width. - int32_t narrow4_max{0}; - int32_t narrow8_max{0}; - - // Settings read at solve entry, where the climber carries populated values. int32_t seed{0}; int32_t tabu_tenure_min{3}; @@ -545,15 +517,7 @@ struct fj_bin_engine_t : fj_binary_state_t { int32_t max_aggregate_base{0}; int32_t max_aggregate_bonus{0}; - int coefficient_bits() const override { return 8 * (int)sizeof(coef_t); } - int n_split_constraints() const override { return pb.n_constraints; } - i_t iterations() const override { return (i_t)iters; } - - void saturation(int& base_peak, int& bonus_peak) const override - { - base_peak = max_aggregate_base; - bonus_peak = max_aggregate_bonus; - } + int coefficient_bits() const { return 8 * (int)sizeof(coef_t); } // Largest per-variable aggregate base and bonus under the final weights and assignment, in raw // int32. The packed representation is only order-preserving while these stay inside their @@ -762,11 +726,8 @@ struct fj_bin_engine_t : fj_binary_state_t { const int32_t margin = (int32_t)rcmax_p[ii]; if (!(old_slack < -margin && new_slack < -margin)) { const int32_t kb = offsets_p[r], ke = offsets_p[r + 1]; - // The offsets are already loaded for the call, so the width choice is a compare rather - // than a stored per-row flag. // TODO: check that this may not cause AVX512 powerdown overheads if the AVX2 row/AVX512 row ratio is unbalanced - fj_bin_patch_row(fj_bin_patch_width_for(ke - kb, narrow4_max, narrow8_max), - vars_p, + fj_bin_patch_row(vars_p, coefs_p, kb, ke, @@ -854,8 +815,7 @@ struct fj_bin_engine_t : fj_binary_state_t { if (new_weight > max_weight) max_weight = new_weight; // The slack is unchanged here, and no variable is excluded, so skip_var matches no index. const int32_t kb = pb.offsets[r], ke = pb.offsets[r + 1]; - fj_bin_patch_row(fj_bin_patch_width_for(ke - kb, narrow4_max, narrow8_max), - pb.variables.data(), + fj_bin_patch_row(pb.variables.data(), pb.coefficients.data(), kb, ke, @@ -1042,8 +1002,6 @@ struct fj_bin_engine_t : fj_binary_state_t { { const auto& params = climber.settings.parameters; seed = climber.settings.seed; - narrow4_max = fj_bin_simd_narrow4_max(); - narrow8_max = fj_bin_simd_narrow8_max(); rng = raft::random::PCGenerator((uint64_t)seed, 0, 0); tabu_tenure_min = params.tabu_tenure_min; tabu_tenure_max = params.tabu_tenure_max; @@ -1057,9 +1015,9 @@ struct fj_bin_engine_t : fj_binary_state_t { // iterations. A tenure that long would be overwritten while the variable is still tabu, and the // argmax would stop excluding it. Clamped as well as asserted: release builds compile the assert // out, and silently dropping tabu entries is worse than a shorter tenure. - cuopt_assert(tabu_tenure_max <= fj_bin_tabu_t::ring_size, + cuopt_assert(tabu_tenure_max <= fj_bin_tabu_t::max_tenure, "tabu tenure exceeds the tabu ring, live entries would be evicted"); - if (tabu_tenure_max > fj_bin_tabu_t::ring_size) tabu_tenure_max = fj_bin_tabu_t::ring_size; + if (tabu_tenure_max > fj_bin_tabu_t::max_tenure) tabu_tenure_max = fj_bin_tabu_t::max_tenure; const int32_t n = pb.n_variables, m = pb.n_constraints; const auto& h_assign = climber.h_assignment; @@ -1079,7 +1037,7 @@ struct fj_bin_engine_t : fj_binary_state_t { row_slack.assign(m, 0); var_score.assign(n, 0); - nnz_score_delta.assign(pb.nnz + fj_bin_simd_padding(), 0); + nnz_score_delta.assign(pb.nnz + fj_bin_simd_padding, 0); tabu.resize(n); is_violated.assign(m, 0); vpos.assign(m, -1); @@ -1096,7 +1054,7 @@ struct fj_bin_engine_t : fj_binary_state_t { recompute_slack(); } - void solve(fj_cpu_climber_t& climber, f_t time_limit, double work_unit_limit) override + void solve(fj_cpu_climber_t& climber, f_t time_limit, double work_unit_limit) { init(climber); @@ -1109,7 +1067,7 @@ struct fj_bin_engine_t : fj_binary_state_t { if (bounded_time && std::chrono::high_resolution_clock::now() - loop_start > limit) break; if (iters >= climber.settings.iteration_limit) break; if (iters - last_restart_iter >= fj_bin_restart_period) do_restart(); - tabu.maybe_advance(iters); + tabu.maybe_rebase(iters); int32_t move_var = -1, score = fj_bin_score_invalid; if (violated_list.empty()) std::tie(move_var, score) = find_lift_move(); @@ -1182,13 +1140,9 @@ struct fj_bin_engine_t : fj_binary_state_t { }; template -void fj_binary_state_deleter_t::operator()(fj_binary_state_t* ptr) const -{ - delete ptr; -} - -template -void try_build_binary_fastpath(fj_cpu_climber_t& climber) +bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, + f_t time_limit, + double work_unit_limit) { const fj_bin_scan_t scan = fj_bin_scan(climber); if (scan.reject != fj_binary_reject_t::none) { @@ -1197,41 +1151,43 @@ void try_build_binary_fastpath(fj_cpu_climber_t& climber) fj_binary_reject_name(scan.reject), scan.bad_row, scan.bad_var); - return; + return false; } - bool built = false; - if (scan.coefficient_bits == 8) { - auto engine = std::make_unique>(); - built = fj_bin_narrow(climber, scan.n_split_constraints, engine->pb); - if (built) climber.binary_fast.reset(engine.release()); - } else { - auto engine = std::make_unique>(); - built = fj_bin_narrow(climber, scan.n_split_constraints, engine->pb); - if (built) climber.binary_fast.reset(engine.release()); - } + auto run = [&](auto& engine) -> bool { + if (!fj_bin_narrow(climber, scan.n_split_constraints, engine.pb)) { + CUOPT_LOG_DEBUG("%sCPUFJ binary fast path declined: %s", + climber.log_prefix.c_str(), + fj_binary_reject_name(fj_binary_reject_t::narrow_check_failed)); + return false; + } + CUOPT_LOG_DEBUG( + "%sCPUFJ binary fast path enabled: int%d coefficients, %d rows after one-sided split", + climber.log_prefix.c_str(), + scan.coefficient_bits, + scan.n_split_constraints); + engine.solve(climber, time_limit, work_unit_limit); + return true; + }; - if (!built) { - CUOPT_LOG_DEBUG("%sCPUFJ binary fast path declined: %s", - climber.log_prefix.c_str(), - fj_binary_reject_name(fj_binary_reject_t::narrow_check_failed)); - return; + if (scan.coefficient_bits == 8) { + fj_bin_engine_t engine; + return run(engine); } - - CUOPT_LOG_DEBUG("%sCPUFJ binary fast path enabled: int%d coefficients, %d rows after one-sided split", - climber.log_prefix.c_str(), - scan.coefficient_bits, - scan.n_split_constraints); + fj_bin_engine_t engine; + return run(engine); } #if MIP_INSTANTIATE_FLOAT -template struct fj_binary_state_deleter_t; -template void try_build_binary_fastpath(fj_cpu_climber_t& climber); +template bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, + float time_limit, + double work_unit_limit); #endif #if MIP_INSTANTIATE_DOUBLE -template struct fj_binary_state_deleter_t; -template void try_build_binary_fastpath(fj_cpu_climber_t& climber); +template bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, + double time_limit, + double work_unit_limit); #endif } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh index d6daf9c160..e7cc1a2818 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh @@ -8,14 +8,9 @@ #pragma once #include -#include -// Seam between the general CPU FJ path and the binary fast path. Only fj_cpu.cu includes this; -// fj_cpu.cuh forward-declares fj_binary_state_t so the climber can hold one without the general -// header depending on the fast path. -// // The fast path applies to instances whose variables are all binary and whose rows carry integer -// coefficients within int8 or int16 range. On those it runs an integer engine: exact feasibility +// coefficients within int8 or int16 range. On those it runs a SIMD integer engine: exact feasibility // against a single row bound, a live per-variable score patched through stored per-nnz // contributions, and a global argmax move selection. @@ -24,7 +19,6 @@ namespace cuopt::mathematical_optimization::mip { template struct fj_cpu_climber_t; -// Why an instance was refused, logged at DEBUG by the build entry. enum class fj_binary_reject_t : uint8_t { none, empty_problem, @@ -36,49 +30,16 @@ enum class fj_binary_reject_t : uint8_t { lhs_headroom, narrow_check_failed, }; - const char* fj_binary_reject_name(fj_binary_reject_t reason); -// Built state for one eligible instance. The concrete engine is templated on coefficient width -// (int8_t or int16_t) and lives in fj_cpu_binary.cu; the width choice is made once at build time, -// so dispatching through this base costs one virtual call per solve. -template -struct fj_binary_state_t { - virtual ~fj_binary_state_t() = default; - - virtual void solve(fj_cpu_climber_t& climber, - f_t time_limit, - double work_unit_limit) = 0; - - virtual int coefficient_bits() const = 0; // 8 or 16 - virtual int n_split_constraints() const = 0; - virtual i_t iterations() const = 0; - - // Largest per-variable aggregate base and bonus observed at end of solve. The packed score is - // order-preserving only while these stay under 2^16 and 2^14 respectively. - virtual void saturation(int& max_aggregate_base, int& max_aggregate_bonus) const = 0; -}; - -// Runs predicate -> one-sided split -> narrow. A pure function of the climber's host problem -// mirrors and its incoming constraint weights, so it is well defined wherever those are populated. -// Populates climber.binary_fast on success; leaves it empty and logs the reason otherwise. +// Returns true if the fast path ran (eligible and narrowed); false if declined, in which case the caller should take the general path. +// TODO: worth revisiting if the same climber is solved repeatedly to cache the fastpath state template -void try_build_binary_fastpath(fj_cpu_climber_t& climber); +bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, + f_t time_limit, + double work_unit_limit); -// --------------------------------------------------------------------------------------------- -// Hot kernels and the score-delta formula they share with the engine. -// -// The kernels live in fj_cpu_binary_kernels.cpp, built with Google Highway, which compiles the -// bodies once per SIMD target and dispatches at runtime. That file is host-compiled: nvcc's -// frontend rejects Highway's x86 headers, which reinterpret-cast intrinsic vectors to -// compiler-specific vector types (GCC vector extensions in the constant-folding path, __m128bh -// for bfloat16). Every argument below is a plain pointer or scalar, so the seam names no cuOpt or -// CUDA type. -// --------------------------------------------------------------------------------------------- - -// Packed staged score: one int32 holding base * K + bonus. K exceeds twice the largest |bonus| the -// engine produces, so integer ordering on the packed word reproduces the lexicographic (base, -// bonus) ordering the general path gets from fj_staged_score_t. +// Packed staged score: one int32 holding base * K + bonus constexpr int32_t fj_bin_score_shift = 15; constexpr int32_t fj_bin_score_k = 1 << fj_bin_score_shift; constexpr int32_t fj_bin_score_invalid = INT32_MIN; @@ -87,9 +48,7 @@ constexpr int32_t fj_bin_score_invalid = INT32_MIN; // (os) and after (ns) that flip. base is the weighted change in satisfaction; bonus is the // weighted change in strict slack. When both states are violated the improving direction earns // half weight, matching excess_improvement_weight of 1/2. -// -// Single source of the formula: the engine scores moves with it, compute_saturation walks it, and -// the vector kernels reproduce it lane-wise. +// purpose: implements the scoring delta logic from feasibility_jump.cuh in a form easier to port to SIMD static inline void fj_bin_score_delta_parts( int32_t os, int32_t ns, int32_t weight, int32_t& base, int32_t& bonus) { @@ -107,43 +66,14 @@ static inline int32_t fj_bin_packed_score_delta(int32_t os, int32_t ns, int32_t return base * fj_bin_score_k + bonus; } -// Elements of padding the per-nnz arrays must carry past nnz, in int32 units. A target that masks -// its row remainder loads and stores a whole vector at the last row of the matrix, and the padding -// is what keeps that off memory it does not own. A target that peels the remainder into a scalar -// tail stops at the row end and asks for nothing, so this returns 0 there. -int32_t fj_bin_simd_padding(); - -// Vector width the row patch runs at. A gather costs the same whether its lanes carry data or are -// masked off, so a row filling only part of a native vector is cheaper through a narrower one; past -// the crossover the extra vector and its extra full gather cost more than the idle lanes. -enum class fj_bin_patch_width_t : int32_t { narrow4 = 0, narrow8 = 1, native = 2 }; - -// Longest row for which each narrower width beats the native one, or 0 where that width is not -// worth offering on this target: a width is offered only when it is strictly narrower than the -// native vector, and scalable targets decline both. -int32_t fj_bin_simd_narrow4_max(); -int32_t fj_bin_simd_narrow8_max(); - -static inline fj_bin_patch_width_t fj_bin_patch_width_for(int32_t row_len, - int32_t narrow4_max, - int32_t narrow8_max) -{ - if (row_len <= narrow4_max) return fj_bin_patch_width_t::narrow4; - if (row_len <= narrow8_max) return fj_bin_patch_width_t::narrow8; - return fj_bin_patch_width_t::native; -} +// Padding margin to prevent faults on tail SIMD loads +constexpr int32_t fj_bin_simd_padding = 256; -// Patch every variable of one row against the row's current signed slack, skipping skip_var. The +// Patch every variable of one row against the row's current signed slack. The // move case passes the post-move slack and the flipped variable's index; the reweight case passes // the unchanged slack and -1, which matches no variable index. -// -// Reads and writes up to fj_bin_simd_padding() elements from kb, so variables, coefficients and -// nnz_score_delta must carry that padding. -// -// Defined in the host-compiled kernels TU and explicitly instantiated there for int8_t and int16_t. template -void fj_bin_patch_row(fj_bin_patch_width_t width, - const int32_t* variables, +void fj_bin_patch_row(const int32_t* variables, const coef_t* coefficients, int32_t kb, int32_t ke, @@ -155,48 +85,15 @@ void fj_bin_patch_row(fj_bin_patch_width_t width, int32_t os_new, int32_t skip_var); -// Incidences handed to fj_bin_walk_rows per call, and so the size of the caller's output buffer. -// Large enough that the dynamic dispatch and the call's setup amortize away -- a variable of -// supportcase22 averages 344 incidences, so one or two calls per move -- and small enough that the -// buffer is a stack array and its touched part stays in L1. A multiple of every supported lane -// count, so only a variable's last tile has a masked remainder. constexpr int32_t fj_bin_walk_tile = 256; -// Advance every row incident to one flipped variable, and report which of those visits the caller -// must finish by hand. -// -// An "incidence" is one (variable, row) pair of the matrix, so the reverse CSR indexed by a -// variable's [incidence_begin, incidence_end) names the rows that variable appears in. Three arrays -// are read at that index, all at unit stride: -// -// incident_row[i] the row this incidence touches -// signed_coefficient[i] sign * coefficient, folded once at build time -// incident_row_cmax[i] that row's max|coef|, replicated here so it need not be gathered -// -// The last two are structural and fixed for the life of the solve. row_slack[r] is indexed by row, -// and holds sign * (bound - lhs) -- the signed slack the engine stores in place of lhs, which is -// what reduces the update to a subtraction. delta is the flip direction, +1 or -1, uniform over the -// call. +// Advance every row incident to one flipped variable within apply_move, and report which of those visits the caller +// must finish by hand (e.g. if the row needs patching) // // For every incidence i in the range this applies // row_slack[incident_row[i]] -= signed_coefficient[i] * delta // then writes to out_incidence, in increasing order, the subset of i whose row is not deeply -// satisfied on both sides of the flip -- 15.1% of visits on supportcase22 -- and returns how many. -// Everything those visits still need is indirect (the row's weight, the own-score delta, the -// violated-set transitions, the patch) and stays with the caller. -// -// The caller drives this a tile at a time, running each tile's tail before asking for the next, so -// out_incidence is a fj_bin_walk_tile-sized buffer the caller can keep on its stack rather than one -// sized to the widest reverse degree in the matrix. Tiling this way rather than interleaving the -// tail into the vector loop keeps every patch call outside it: the loop over lanes lives here, -// behind a dynamic dispatch pointer that cannot be inlined, while the tail needs engine-side state -// this translation unit cannot reach. A tile still runs the vector body many times before yielding, -// which is what lets consecutive gathers overlap. -// -// out_incidence must hold incidence_end - incidence_begin entries. Reads up to -// fj_bin_simd_padding() elements past incidence_end from the three per-incidence arrays. Interior -// tiles read into the next tile, which is in bounds; the final one reads past the variable's range, -// so those arrays carry that padding. +// satisfied on both sides of the flip and returns how many. template int32_t fj_bin_walk_rows(int32_t* row_slack, const int32_t* incident_row, @@ -209,10 +106,8 @@ int32_t fj_bin_walk_rows(int32_t* row_slack, // Argmax over var_score, scanning all n variables. Valid while the objective weight is zero, where // the full score is exactly var_score. Yields best_var of -1 only if n is 0. -// -// Carries no tabu argument: at most a ring's worth of variables are tabu at once, so the caller -// holds those few at fj_bin_score_invalid across the call instead of making this read a per-variable -// tenure array. That keeps the scan to four bytes per variable. +// Tabu is handled by "blocking" the scores corresponding to the tabu vars, and restoring them after the argmax +// affordable since max_tenure is small void fj_bin_argmax(const int32_t* var_score, int32_t n, int32_t tile, diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp index 90affda3fb..08125f44b3 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp @@ -36,13 +36,6 @@ namespace hn = hwy::HWY_NAMESPACE; constexpr bool k_mask_remainder = (HWY_TARGET <= HWY_AVX3) || HWY_TARGET_IS_SVE || (HWY_TARGET == HWY_RVV); -// Padding the caller must carry past nnz. Only a masking target reads past a row's end; a peeling -// target stops at ke, so it asks for nothing and its buffers keep their natural size. -int32_t PaddingImpl() -{ - return k_mask_remainder ? (int32_t)hn::Lanes(hn::ScalableTag()) : 0; -} - // Whether the row walk below is worth vectorizing on this target. It needs a real gather to read the // slacks and a real compress to emit the tail list; where either is emulated the emulation costs // more than the scalar loop it replaces, since 85% of visits do nothing but subtract and compare. @@ -420,7 +413,6 @@ HWY_EXPORT_T(PatchRowNatI16, PatchRowImpl); HWY_EXPORT_T(PatchRowN8I16, PatchRowNarrow8Impl); HWY_EXPORT_T(PatchRowN4I16, PatchRowNarrow4Impl); HWY_EXPORT(ArgmaxImpl); -HWY_EXPORT(PaddingImpl); HWY_EXPORT(Narrow4MaxImpl); HWY_EXPORT(Narrow8MaxImpl); @@ -455,7 +447,7 @@ using fj_bin_patch_fn_t = void (*)(const int32_t*, int32_t, int32_t); -// Indexed by fj_bin_patch_width_t. +// Indexed by width: 0 = 4-lane, 1 = 8-lane, 2 = native. static const fj_bin_patch_fn_t fj_bin_patch_i8[3] = { (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowN4I8)), (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowN8I8)), @@ -472,6 +464,24 @@ static const fj_bin_patch_fn_t fj_bin_patch_i16[3] = { static const fj_bin_patch_fn_t* fj_bin_patch_table(int8_t) { return fj_bin_patch_i8; } static const fj_bin_patch_fn_t* fj_bin_patch_table(int16_t) { return fj_bin_patch_i16; } +static const auto fj_bin_narrow4_max_fn = + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(Narrow4MaxImpl)); +static const auto fj_bin_narrow8_max_fn = + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(Narrow8MaxImpl)); + +// Resolved once at load: a gather costs the same whether its lanes carry data or are masked off, so +// a row filling only part of a native vector is cheaper through a narrower one. Scalable targets +// decline both (see Narrow*MaxImpl). +static const int32_t fj_bin_n4_max = fj_bin_narrow4_max_fn(); +static const int32_t fj_bin_n8_max = fj_bin_narrow8_max_fn(); + +static int32_t fj_bin_patch_width_index(int32_t row_len) +{ + if (row_len <= fj_bin_n4_max) return 0; + if (row_len <= fj_bin_n8_max) return 1; + return 2; +} + template using fj_bin_walk_fn_t = int32_t (*)( int32_t*, const int32_t*, const coef_t*, const coef_t*, int32_t, int32_t, int32_t, int32_t*); @@ -485,13 +495,6 @@ static fj_bin_walk_fn_t fj_bin_walk_fn(int8_t) { return fj_bin_walk_i8; static fj_bin_walk_fn_t fj_bin_walk_fn(int16_t) { return fj_bin_walk_i16; } static const auto fj_bin_argmax_fn = (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(ArgmaxImpl)); -static const auto fj_bin_padding_fn = (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(PaddingImpl)); -static const auto fj_bin_narrow4_max_fn = - (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(Narrow4MaxImpl)); -static const auto fj_bin_narrow8_max_fn = - (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(Narrow8MaxImpl)); - -int32_t fj_bin_simd_padding() { return fj_bin_padding_fn(); } template int32_t fj_bin_walk_rows(int32_t* row_slack, @@ -518,12 +521,8 @@ template int32_t fj_bin_walk_rows( template int32_t fj_bin_walk_rows( int32_t*, const int32_t*, const int16_t*, const int16_t*, int32_t, int32_t, int32_t, int32_t*); -int32_t fj_bin_simd_narrow4_max() { return fj_bin_narrow4_max_fn(); } -int32_t fj_bin_simd_narrow8_max() { return fj_bin_narrow8_max_fn(); } - template -void fj_bin_patch_row(fj_bin_patch_width_t width, - const int32_t* variables, +void fj_bin_patch_row(const int32_t* variables, const coef_t* coefficients, int32_t kb, int32_t ke, @@ -535,13 +534,14 @@ void fj_bin_patch_row(fj_bin_patch_width_t width, int32_t os_new, int32_t skip_var) { - fj_bin_patch_table(coef_t{})[(int)width](variables, coefficients, kb, ke, var_score, - nnz_score_delta, assign_i32, sign, weight, os_new, - skip_var); + // The offsets are already in hand, so the width choice is a compare rather than a stored per-row + // flag. + fj_bin_patch_table(coef_t{})[fj_bin_patch_width_index(ke - kb)]( + variables, coefficients, kb, ke, var_score, nnz_score_delta, assign_i32, sign, weight, os_new, + skip_var); } -template void fj_bin_patch_row(fj_bin_patch_width_t, - const int32_t*, +template void fj_bin_patch_row(const int32_t*, const int8_t*, int32_t, int32_t, @@ -553,8 +553,7 @@ template void fj_bin_patch_row(fj_bin_patch_width_t, int32_t, int32_t); -template void fj_bin_patch_row(fj_bin_patch_width_t, - const int32_t*, +template void fj_bin_patch_row(const int32_t*, const int16_t*, int32_t, int32_t, From 2dc1f79594a4e7f1d6b97e13ea94305df184976c Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Fri, 7 Aug 2026 07:45:41 -0700 Subject: [PATCH 25/78] perf: fold the patch width choice into the Highway target The width crossovers are per-target constants, but reaching them from the call seam turned them into loads of runtime globals: the stub compared the row length against fj_bin_n4_max and fj_bin_n8_max, selected a table entry with a cmov, loaded the pointer and jumped through it. That put an unpredictable branch on row length directly in front of an indirect jump, in a serialized cmp -> select -> load -> jmp chain, with two extra callee-saved registers spilled around it. Decide the width inside HWY_NAMESPACE instead, where the crossovers are constexpr and the three kernels are direct calls, and export one entry point per coefficient width. On a scalable target both bounds are 0, so the compares fold away and the narrow arms are stripped, which is what the runtime HWY_HAVE_SCALABLE check did before. The seam collapses from 28 instructions to one: jmp *fj_bin_patch_i8(%rip) supportcase22, 20s x 16 climbers, mean of 3: 4.243 -> 4.354 M iters/s (+2.6%), which recovers the 2.4% this seam had cost and lands slightly ahead of the shape that preceded it. Search behaviour is unchanged (1/16 crossed, objective 116). Signed-off-by: Alice Boucher --- .../fj_cpu_binary_kernels.cpp | 104 ++++++++---------- 1 file changed, 48 insertions(+), 56 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp index 08125f44b3..5ceb5914aa 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp @@ -326,16 +326,41 @@ void PatchRowNarrow4Impl(const int32_t* HWY_RESTRICT variables, // dispatches to a kernel identical to its own. Scalable targets opt out entirely: Highway notes // that clamping Lanes() on RVV/SVE can cost more than the capping saves, which is why // CappedTagIfFixed leaves them at native width above. -int32_t Narrow4MaxImpl() -{ - if (HWY_HAVE_SCALABLE) return 0; - return hn::Lanes(hn::ScalableTag()) > 4 ? 4 : 0; -} - -int32_t Narrow8MaxImpl() +// +// These are per-target constants, so the width choice belongs here rather than at the call seam: a +// caller outside this file can only reach them through a dispatch pointer, which turns two +// immediates into two loads of runtime globals and puts an unpredictable branch directly in front +// of the indirect jump that follows it. Measured on supportcase22, that seam cost 2.4%. +constexpr size_t k_native_lanes = HWY_MAX_LANES_D(hn::ScalableTag); +constexpr int32_t k_narrow4_max = HWY_HAVE_SCALABLE ? 0 : (k_native_lanes > 4 ? 4 : 0); +constexpr int32_t k_narrow8_max = HWY_HAVE_SCALABLE ? 0 : (k_native_lanes > 8 ? 8 : 0); + +// Single entry point the seam dispatches to. On a scalable target both bounds are 0, so both +// compares fold away and the narrow arms are stripped. +template +void PatchRowDispatchImpl(const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int32_t* HWY_RESTRICT var_score, + int32_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t sign, + int32_t weight, + int32_t os_new, + int32_t skip_var) { - if (HWY_HAVE_SCALABLE) return 0; - return hn::Lanes(hn::ScalableTag()) > 8 ? 8 : 0; + const int32_t row_len = ke - kb; + if (row_len <= k_narrow4_max) { + PatchRowNarrow4Impl(variables, coefficients, kb, ke, var_score, nnz_score_delta, + assign_i32, sign, weight, os_new, skip_var); + } else if (row_len <= k_narrow8_max) { + PatchRowNarrow8Impl(variables, coefficients, kb, ke, var_score, nnz_score_delta, + assign_i32, sign, weight, os_new, skip_var); + } else { + PatchRowImpl(variables, coefficients, kb, ke, var_score, nnz_score_delta, assign_i32, + sign, weight, os_new, skip_var); + } } // Tiled sweep carrying a running maximum. The index re-scan fires only on a tile that raises it, @@ -404,17 +429,11 @@ namespace cuopt::mathematical_optimization::mip { // separately from the function, which lets the function be a template-id: only the table name goes // through token pasting, so no hand-written non-template wrapper is needed. The template argument // must stay comma-free, which is why the three tag-binding wrappers above take only coef_t. -HWY_EXPORT_T(PatchRowNatI8, PatchRowImpl); -HWY_EXPORT_T(PatchRowN8I8, PatchRowNarrow8Impl); -HWY_EXPORT_T(PatchRowN4I8, PatchRowNarrow4Impl); +HWY_EXPORT_T(PatchRowI8, PatchRowDispatchImpl); +HWY_EXPORT_T(PatchRowI16, PatchRowDispatchImpl); HWY_EXPORT_T(WalkRowsI8, WalkRowsImpl); HWY_EXPORT_T(WalkRowsI16, WalkRowsImpl); -HWY_EXPORT_T(PatchRowNatI16, PatchRowImpl); -HWY_EXPORT_T(PatchRowN8I16, PatchRowNarrow8Impl); -HWY_EXPORT_T(PatchRowN4I16, PatchRowNarrow4Impl); HWY_EXPORT(ArgmaxImpl); -HWY_EXPORT(Narrow4MaxImpl); -HWY_EXPORT(Narrow8MaxImpl); // HWY_DYNAMIC_DISPATCH resolves the target on every call, and the hwy::GetChosenTarget() call it // expands to is a real out-of-line call: it clobbers the argument registers, so the compiler spills @@ -447,40 +466,16 @@ using fj_bin_patch_fn_t = void (*)(const int32_t*, int32_t, int32_t); -// Indexed by width: 0 = 4-lane, 1 = 8-lane, 2 = native. -static const fj_bin_patch_fn_t fj_bin_patch_i8[3] = { - (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowN4I8)), - (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowN8I8)), - (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowNatI8)), -}; - -static const fj_bin_patch_fn_t fj_bin_patch_i16[3] = { - (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowN4I16)), - (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowN8I16)), - (fj_bin_choose_target(), HWY_DYNAMIC_POINTER_T(PatchRowNatI16)), -}; - -// Overloaded rather than specialized so the tables stay plain arrays. -static const fj_bin_patch_fn_t* fj_bin_patch_table(int8_t) { return fj_bin_patch_i8; } -static const fj_bin_patch_fn_t* fj_bin_patch_table(int16_t) { return fj_bin_patch_i16; } - -static const auto fj_bin_narrow4_max_fn = - (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(Narrow4MaxImpl)); -static const auto fj_bin_narrow8_max_fn = - (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(Narrow8MaxImpl)); - -// Resolved once at load: a gather costs the same whether its lanes carry data or are masked off, so -// a row filling only part of a native vector is cheaper through a narrower one. Scalable targets -// decline both (see Narrow*MaxImpl). -static const int32_t fj_bin_n4_max = fj_bin_narrow4_max_fn(); -static const int32_t fj_bin_n8_max = fj_bin_narrow8_max_fn(); - -static int32_t fj_bin_patch_width_index(int32_t row_len) -{ - if (row_len <= fj_bin_n4_max) return 0; - if (row_len <= fj_bin_n8_max) return 1; - return 2; -} +// The vector width is chosen inside the target (see PatchRowDispatchImpl), so the seam carries one +// pointer per coefficient width and nothing else. +static const auto fj_bin_patch_i8 = + (fj_bin_choose_target(), (fj_bin_patch_fn_t)HWY_DYNAMIC_POINTER_T(PatchRowI8)); +static const auto fj_bin_patch_i16 = + (fj_bin_choose_target(), (fj_bin_patch_fn_t)HWY_DYNAMIC_POINTER_T(PatchRowI16)); + +// Overloaded rather than specialized, matching fj_bin_walk_fn below. +static fj_bin_patch_fn_t fj_bin_patch_fn(int8_t) { return fj_bin_patch_i8; } +static fj_bin_patch_fn_t fj_bin_patch_fn(int16_t) { return fj_bin_patch_i16; } template using fj_bin_walk_fn_t = int32_t (*)( @@ -534,11 +529,8 @@ void fj_bin_patch_row(const int32_t* variables, int32_t os_new, int32_t skip_var) { - // The offsets are already in hand, so the width choice is a compare rather than a stored per-row - // flag. - fj_bin_patch_table(coef_t{})[fj_bin_patch_width_index(ke - kb)]( - variables, coefficients, kb, ke, var_score, nnz_score_delta, assign_i32, sign, weight, os_new, - skip_var); + fj_bin_patch_fn(coef_t{})(variables, coefficients, kb, ke, var_score, nnz_score_delta, assign_i32, + sign, weight, os_new, skip_var); } template void fj_bin_patch_row(const int32_t*, From eb4ce7561e8eef61ac03610224037354c8987921 Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Fri, 7 Aug 2026 08:02:16 -0700 Subject: [PATCH 26/78] perf: normalize every row to a'x <= b and drop the per-row sign The one-sided split emitted the lower-bound side as a'x >= lb and carried an int8 sign per row to reconstruct the slack as sign * (bound - lhs). Negating that side's coefficients and bound at build time makes every row a'x <= b, so the slack is bound - lhs everywhere and the sign disappears from the record, the kernels and every rebuild path. This costs nothing to store: emit() already pushes a separate coefficient copy per side. Negation is safe on both fields -- the eligibility scan admits |coef| up to 127 for int8 and 32767 for int16, and the bound is range-checked after negating rather than before. With sign == 1 everywhere, signed_coefficient became a plain transpose-order copy of the coefficients, i.e. identical to reverse_coefficients, which was written but never read. The two merge under the latter name, which is what the array now is. Removed: pb.sign (0.26 MB), one of the two per-nnz coefficient copies (2.2 MB), the sign field of fj_bin_row_t (leaving a bare weight), one argument from fj_bin_patch_row, and the vsign multiply from both the vector and scalar patch arms. supportcase22, 20s x 16 climbers, mean of 3: 4.354 -> 4.396 M iters/s (+0.96%). Trajectory is bit-identical: 84951 common (climber, iteration) samples across all 16 climbers match on viol, best and maxw, and the incumbent audit passes on every run. Signed-off-by: Alice Boucher --- .../feasibility_jump/fj_cpu_binary.cu | 70 +++++++++---------- .../feasibility_jump/fj_cpu_binary.cuh | 5 +- .../fj_cpu_binary_kernels.cpp | 48 +++++-------- 3 files changed, 53 insertions(+), 70 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index a63d3ad802..f43a37aec0 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -149,15 +149,14 @@ struct fj_bin_tabu_t { // What the apply path still needs per row once the row walk is vectorized: everything else it used // to read from here now reaches it at unit stride. // -// The mutable state moved out to the engine's row_slack, which holds sign * (bound - lhs) rather -// than lhs, because that is what turns the walk's update into a subtraction and lets it gather one -// array instead of four. bound went with it -- only the rebuild paths need it, and they read -// pb.bound. cmax went to pb.incident_row_cmax, replicated per incidence. sign stays because -// fj_bin_patch_row still takes it, and weight because it is genuinely mutable. +// The mutable state moved out to the engine's row_slack, which holds bound - lhs rather than lhs, +// because that is what turns the walk's update into a subtraction and lets it gather one array +// instead of four. bound went with it -- only the rebuild paths need it, and they read pb.bound. +// cmax went to pb.incident_row_cmax, replicated per incidence. Every row is stored as a'x <= b, so +// there is no sign to carry. Only the weight is left, and it is genuinely mutable. template struct fj_bin_row_t { int32_t weight; - int8_t sign; }; // Narrowed problem: one-sided rows, integer coefficients, CSR plus its transpose. @@ -173,17 +172,15 @@ struct fj_bin_problem_t { std::vector reverse_offsets; std::vector reverse_constraints; - std::vector reverse_coefficients; std::vector reverse_to_csr; - // Per incidence, for the vectorized row walk: sign * coefficient folded once, and the row's cmax - // replicated. Both are structural. Indexed like the transpose above, so the walk reads them at - // unit stride instead of gathering sign, coefficient and cmax per row. - std::vector signed_coefficient; + // Per incidence, for the vectorized row walk: the coefficient and the row's cmax, both replicated + // in transpose order so the walk reads them at unit stride instead of gathering per row. Both are + // structural. + std::vector reverse_coefficients; std::vector incident_row_cmax; std::vector bound; - std::vector sign; std::vector cmax; std::vector initial_weight; @@ -335,7 +332,6 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, pb.offsets.assign(1, 0); pb.offsets.reserve(n_split + 1); pb.bound.reserve(n_split); - pb.sign.reserve(n_split); pb.cmax.reserve(n_split); pb.initial_weight.reserve(n_split); @@ -344,11 +340,17 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, // Each split row inherits the weight of the side it came from: left is the lower-bound side, // right the upper. - auto emit = [&](int32_t r, double side_bound, int8_t sign, double weight) -> bool { + // + // Both sides are stored as a'x <= b. The lower-bound side is negated on the way in, which costs + // nothing because each side already gets its own copy of the row, and it leaves the slack as + // bound - lhs everywhere -- so no per-row sign reaches the engine at all. Negation is safe on both + // fields: the scan admits |coef| up to 127 for int8 and 32767 for int16, and the bound is checked + // for int32 range after negating. + auto emit = [&](int32_t r, double side_bound, long side, double weight) -> bool { coef_t row_cmax = 1; for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { const double a = coeffs[k]; - const long ai = std::lround(a); + const long ai = side * std::lround(a); if (!is_integer(a, tol) || ai < std::numeric_limits::min() || ai > std::numeric_limits::max()) { return false; @@ -358,11 +360,10 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, const coef_t abs_a = (coef_t)std::labs(ai); if (abs_a > row_cmax) row_cmax = abs_a; } - const long b = std::lround(side_bound); + const long b = side * std::lround(side_bound); if (!fj_bin_in_int32((double)b)) return false; pb.offsets.push_back((int32_t)pb.variables.size()); pb.bound.push_back((int32_t)b); - pb.sign.push_back(sign); pb.cmax.push_back(row_cmax); incoming_weight.push_back(weight); return true; @@ -371,8 +372,8 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, for (int32_t r = 0; r < m; ++r) { const double lb = cstr_lb[r]; const double ub = cstr_ub[r]; - if (std::isfinite(lb) && !emit(r, lb, (int8_t)-1, left_w[r])) return false; - if (std::isfinite(ub) && !emit(r, ub, (int8_t)1, right_w[r])) return false; + if (std::isfinite(lb) && !emit(r, lb, -1, left_w[r])) return false; + if (std::isfinite(ub) && !emit(r, ub, 1, right_w[r])) return false; } if ((int32_t)pb.bound.size() != n_split) return false; pb.nnz = (int32_t)pb.variables.size(); @@ -416,20 +417,16 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, pb.reverse_constraints.resize(pb.nnz); pb.reverse_coefficients.resize(pb.nnz); pb.reverse_to_csr.resize(pb.nnz); - pb.signed_coefficient.resize(pb.nnz); pb.incident_row_cmax.resize(pb.nnz); { std::vector cursor(pb.reverse_offsets.begin(), pb.reverse_offsets.begin() + n); for (int32_t r = 0; r < n_split; ++r) { for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { - const int32_t slot = cursor[pb.variables[k]]++; - pb.reverse_constraints[slot] = r; + const int32_t slot = cursor[pb.variables[k]]++; + pb.reverse_constraints[slot] = r; pb.reverse_coefficients[slot] = pb.coefficients[k]; pb.reverse_to_csr[slot] = k; - // The scan admits int8 only up to |coef| 127 and int16 only up to 32767, so negating a - // coefficient cannot overflow its own width. - pb.signed_coefficient[slot] = (coef_t)(pb.sign[r] * pb.coefficients[k]); - pb.incident_row_cmax[slot] = pb.cmax[r]; + pb.incident_row_cmax[slot] = pb.cmax[r]; } } } @@ -439,7 +436,7 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, const int32_t rpad = fj_bin_pf_dist > fj_bin_simd_padding ? fj_bin_pf_dist : fj_bin_simd_padding; pb.reverse_constraints.resize(pb.nnz + rpad, 0); - pb.signed_coefficient.resize(pb.nnz + rpad, (coef_t)0); + pb.reverse_coefficients.resize(pb.nnz + rpad, (coef_t)0); pb.incident_row_cmax.resize(pb.nnz + rpad, (coef_t)1); pb.objective.resize(n); @@ -458,8 +455,8 @@ struct fj_bin_engine_t { fj_bin_problem_t pb; std::vector> rows; - // Per row, sign * (bound - lhs): negative exactly when the row is violated, and moved by a flip - // by exactly -signed_coefficient. The only mutable state the vectorized walk gathers. + // Per row, bound - lhs: negative exactly when the row is violated, and moved by a flip by exactly + // -reverse_coefficients. The only mutable state the vectorized walk gathers. std::vector row_slack; std::vector assign; @@ -541,7 +538,7 @@ struct fj_bin_engine_t { lhs += (int64_t)pb.coefficients[k] * (int64_t)best_assign[pb.variables[k]]; } if (lhs < INT32_MIN || lhs > INT32_MAX) lhs_overflow = true; - const int64_t slack = (int64_t)pb.sign[r] * ((int64_t)pb.bound[r] - lhs); + const int64_t slack = (int64_t)pb.bound[r] - lhs; if (slack < 0) { ++n_violated; if (-slack > worst) worst = -slack; @@ -582,7 +579,7 @@ struct fj_bin_engine_t { for (int32_t i = pb.reverse_offsets[v]; i < pb.reverse_offsets[v + 1]; ++i) { const fj_bin_row_t& h = rows[pb.reverse_constraints[i]]; const int32_t os = row_slack[pb.reverse_constraints[i]]; - const int32_t ns = os - (int32_t)pb.signed_coefficient[i] * flip; + const int32_t ns = os - (int32_t)pb.reverse_coefficients[i] * flip; int32_t base = 0, bonus = 0; fj_bin_score_delta_parts(os, ns, h.weight, base, bonus); agg_base += base; @@ -624,12 +621,11 @@ struct fj_bin_engine_t { std::fill(var_score.begin(), var_score.end(), 0); for (int32_t r = 0; r < pb.n_constraints; ++r) { const int32_t weight = rows[r].weight; - const int32_t sign = rows[r].sign; const int32_t os = row_slack[r]; for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { const int32_t v = pb.variables[k]; const int32_t flip = 1 - 2 * assign[v]; - const int32_t ns = os - sign * ((int32_t)pb.coefficients[k] * flip); + const int32_t ns = os - (int32_t)pb.coefficients[k] * flip; const int32_t p = fj_bin_packed_score_delta(os, ns, weight); nnz_score_delta[k] = p; var_score[v] += p; @@ -646,7 +642,7 @@ struct fj_bin_engine_t { int32_t lhs = 0; for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) lhs += (int32_t)pb.coefficients[k] * assign[pb.variables[k]]; - const int32_t slack = pb.sign[r] * (pb.bound[r] - lhs); + const int32_t slack = pb.bound[r] - lhs; row_slack[r] = slack; if (slack < 0) set_violated(r); } @@ -693,7 +689,7 @@ struct fj_bin_engine_t { fj_bin_row_t* const rows_p = rows.data(); int32_t* const slack_p = row_slack.data(); const int32_t* const rcon_p = pb.reverse_constraints.data(); - const coef_t* const skv_p = pb.signed_coefficient.data(); + const coef_t* const skv_p = pb.reverse_coefficients.data(); const coef_t* const rcmax_p = pb.incident_row_cmax.data(); const int32_t* const rcsr_p = pb.reverse_to_csr.data(); const int32_t* const offsets_p = pb.offsets.data(); @@ -734,7 +730,6 @@ struct fj_bin_engine_t { var_score_p, nnz_delta_p, assign_p, - h.sign, h.weight, new_slack, var); @@ -822,7 +817,6 @@ struct fj_bin_engine_t { var_score.data(), nnz_score_delta.data(), assign_i32.data(), - h.sign, h.weight, row_slack[r], -1); @@ -1033,7 +1027,7 @@ struct fj_bin_engine_t { rows.resize(m); for (int32_t r = 0; r < m; ++r) - rows[r] = fj_bin_row_t{pb.initial_weight[r], pb.sign[r]}; + rows[r] = fj_bin_row_t{pb.initial_weight[r]}; row_slack.assign(m, 0); var_score.assign(n, 0); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh index e7cc1a2818..9b45af515d 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh @@ -80,7 +80,6 @@ void fj_bin_patch_row(const int32_t* variables, int32_t* var_score, int32_t* nnz_score_delta, const int32_t* assign_i32, - int32_t sign, int32_t weight, int32_t os_new, int32_t skip_var); @@ -91,13 +90,13 @@ constexpr int32_t fj_bin_walk_tile = 256; // must finish by hand (e.g. if the row needs patching) // // For every incidence i in the range this applies -// row_slack[incident_row[i]] -= signed_coefficient[i] * delta +// row_slack[incident_row[i]] -= reverse_coefficients[i] * delta // then writes to out_incidence, in increasing order, the subset of i whose row is not deeply // satisfied on both sides of the flip and returns how many. template int32_t fj_bin_walk_rows(int32_t* row_slack, const int32_t* incident_row, - const coef_t* signed_coefficient, + const coef_t* reverse_coefficients, const coef_t* incident_row_cmax, int32_t incidence_begin, int32_t incidence_end, diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp index 5ceb5914aa..f2da24c580 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp @@ -57,9 +57,9 @@ constexpr bool k_vector_walk = // The layout this assumes is what makes it worth doing. Storing the row's signed slack rather than // its lhs collapses the update to // -// new_slack = old_slack - sign * coef * delta = old_slack - skv[ii] * delta +// new_slack = old_slack - coef * delta // -// so bound and lhs never appear, and sign and coef fold into one per-incidence constant. skv and +// so bound and lhs never appear, and the coefficient is the only per-incidence constant. It and // cmax are replicated per incidence, which makes them unit-stride loads. What remains irregular is // the slack itself: one gather and one scatter per vector, against four gathers and a scatter for a // literal SoA split of the row record. @@ -70,7 +70,7 @@ constexpr bool k_vector_walk = template int32_t WalkRowsImpl(int32_t* HWY_RESTRICT row_slack, const int32_t* HWY_RESTRICT incident_row, - const coef_t* HWY_RESTRICT signed_coefficient, + const coef_t* HWY_RESTRICT reverse_coefficients, const coef_t* HWY_RESTRICT incident_row_cmax, int32_t incidence_begin, int32_t incidence_end, @@ -94,7 +94,7 @@ int32_t WalkRowsImpl(int32_t* HWY_RESTRICT row_slack, const auto active = hn::FirstN(d, (size_t)(incidence_end - ii)); const V rows = hn::LoadU(d, incident_row + ii); - const V skv = hn::PromoteTo(d, hn::LoadU(dc, signed_coefficient + ii)); + const V skv = hn::PromoteTo(d, hn::LoadU(dc, reverse_coefficients + ii)); const V cmax = hn::PromoteTo(d, hn::LoadU(dc, incident_row_cmax + ii)); const V os = hn::MaskedGatherIndex(active, d, row_slack, rows); @@ -130,7 +130,7 @@ int32_t WalkRowsImpl(int32_t* HWY_RESTRICT row_slack, for (; ii < incidence_end; ++ii) { const int32_t row = incident_row[ii]; const int32_t os = row_slack[row]; - const int32_t ns = os - (int32_t)signed_coefficient[ii] * delta; + const int32_t ns = os - (int32_t)reverse_coefficients[ii] * delta; row_slack[row] = ns; const int32_t cmax = (int32_t)incident_row_cmax[ii]; if (!(os > cmax && ns > cmax)) out_incidence[n_out++] = ii; @@ -147,7 +147,6 @@ void PatchRowScalar(const int32_t* HWY_RESTRICT variables, int32_t* HWY_RESTRICT var_score, int32_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, - int32_t sign, int32_t weight, int32_t os_new, int32_t skip_var) @@ -156,7 +155,7 @@ void PatchRowScalar(const int32_t* HWY_RESTRICT variables, const int32_t v = variables[k]; if (v == skip_var) continue; const int32_t flip = 1 - 2 * assign_i32[v]; - const int32_t ns = os_new - sign * ((int32_t)coefficients[k] * flip); + const int32_t ns = os_new - (int32_t)coefficients[k] * flip; const int32_t nc = fj_bin_packed_score_delta(os_new, ns, weight); var_score[v] += nc - nnz_score_delta[k]; nnz_score_delta[k] = nc; @@ -175,7 +174,6 @@ static HWY_INLINE void PatchRowBody(D d, int32_t* HWY_RESTRICT var_score, int32_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, - int32_t sign, int32_t weight, int32_t os_new, int32_t skip_var) @@ -189,13 +187,13 @@ static HWY_INLINE void PatchRowBody(D d, if constexpr (!k_mask_remainder) { if ((size_t)(ke - kb) < N) { PatchRowScalar(variables, coefficients, kb, ke, var_score, nnz_score_delta, - assign_i32, sign, weight, os_new, skip_var); + assign_i32, weight, os_new, skip_var); return; } } const V vone = hn::Set(d, 1), vzero = hn::Zero(d); - const V vsign = hn::Set(d, sign), vskip = hn::Set(d, skip_var); + const V vskip = hn::Set(d, skip_var); const V vos = hn::Set(d, os_new); const V vw = hn::Set(d, weight), vw2 = hn::Set(d, weight / 2); @@ -223,7 +221,7 @@ static HWY_INLINE void PatchRowBody(D d, const V flip = vone - hn::ShiftLeft<1>(a01); const V coef = hn::PromoteTo(d, hn::LoadU(dc, coefficients + k)); - const V ns = vos - vsign * coef * flip; + const V ns = vos - coef * flip; const V nsat = hn::IfThenElseZero(hn::Ge(ns, vzero), vone); const V nst = hn::IfThenElseZero(hn::Gt(ns, vzero), vone); @@ -260,7 +258,7 @@ static HWY_INLINE void PatchRowBody(D d, if constexpr (!k_mask_remainder) { PatchRowScalar(variables, coefficients, k, ke, var_score, nnz_score_delta, assign_i32, - sign, weight, os_new, skip_var); + weight, os_new, skip_var); } } @@ -273,13 +271,12 @@ void PatchRowImpl(const int32_t* HWY_RESTRICT variables, int32_t* HWY_RESTRICT var_score, int32_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, - int32_t sign, int32_t weight, int32_t os_new, int32_t skip_var) { PatchRowBody(hn::ScalableTag(), variables, coefficients, kb, ke, var_score, - nnz_score_delta, assign_i32, sign, weight, os_new, skip_var); + nnz_score_delta, assign_i32, weight, os_new, skip_var); } template @@ -290,13 +287,12 @@ void PatchRowNarrow8Impl(const int32_t* HWY_RESTRICT variables, int32_t* HWY_RESTRICT var_score, int32_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, - int32_t sign, int32_t weight, int32_t os_new, int32_t skip_var) { PatchRowBody(hn::CappedTagIfFixed(), variables, coefficients, kb, ke, - var_score, nnz_score_delta, assign_i32, sign, weight, os_new, skip_var); + var_score, nnz_score_delta, assign_i32, weight, os_new, skip_var); } template @@ -307,13 +303,12 @@ void PatchRowNarrow4Impl(const int32_t* HWY_RESTRICT variables, int32_t* HWY_RESTRICT var_score, int32_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, - int32_t sign, int32_t weight, int32_t os_new, int32_t skip_var) { PatchRowBody(hn::CappedTagIfFixed(), variables, coefficients, kb, ke, - var_score, nnz_score_delta, assign_i32, sign, weight, os_new, skip_var); + var_score, nnz_score_delta, assign_i32, weight, os_new, skip_var); } // Longest row worth sending to each narrower kernel, or 0 where that width is not worth having. @@ -345,7 +340,6 @@ void PatchRowDispatchImpl(const int32_t* HWY_RESTRICT variables, int32_t* HWY_RESTRICT var_score, int32_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, - int32_t sign, int32_t weight, int32_t os_new, int32_t skip_var) @@ -353,13 +347,13 @@ void PatchRowDispatchImpl(const int32_t* HWY_RESTRICT variables, const int32_t row_len = ke - kb; if (row_len <= k_narrow4_max) { PatchRowNarrow4Impl(variables, coefficients, kb, ke, var_score, nnz_score_delta, - assign_i32, sign, weight, os_new, skip_var); + assign_i32, weight, os_new, skip_var); } else if (row_len <= k_narrow8_max) { PatchRowNarrow8Impl(variables, coefficients, kb, ke, var_score, nnz_score_delta, - assign_i32, sign, weight, os_new, skip_var); + assign_i32, weight, os_new, skip_var); } else { PatchRowImpl(variables, coefficients, kb, ke, var_score, nnz_score_delta, assign_i32, - sign, weight, os_new, skip_var); + weight, os_new, skip_var); } } @@ -463,7 +457,6 @@ using fj_bin_patch_fn_t = void (*)(const int32_t*, const int32_t*, int32_t, int32_t, - int32_t, int32_t); // The vector width is chosen inside the target (see PatchRowDispatchImpl), so the seam carries one @@ -494,7 +487,7 @@ static const auto fj_bin_argmax_fn = (fj_bin_choose_target(), HWY_DYNAMIC_POINTE template int32_t fj_bin_walk_rows(int32_t* row_slack, const int32_t* incident_row, - const coef_t* signed_coefficient, + const coef_t* reverse_coefficients, const coef_t* incident_row_cmax, int32_t incidence_begin, int32_t incidence_end, @@ -503,7 +496,7 @@ int32_t fj_bin_walk_rows(int32_t* row_slack, { return fj_bin_walk_fn(coef_t{})(row_slack, incident_row, - signed_coefficient, + reverse_coefficients, incident_row_cmax, incidence_begin, incidence_end, @@ -524,13 +517,12 @@ void fj_bin_patch_row(const int32_t* variables, int32_t* var_score, int32_t* nnz_score_delta, const int32_t* assign_i32, - int32_t sign, int32_t weight, int32_t os_new, int32_t skip_var) { fj_bin_patch_fn(coef_t{})(variables, coefficients, kb, ke, var_score, nnz_score_delta, assign_i32, - sign, weight, os_new, skip_var); + weight, os_new, skip_var); } template void fj_bin_patch_row(const int32_t*, @@ -542,7 +534,6 @@ template void fj_bin_patch_row(const int32_t*, const int32_t*, int32_t, int32_t, - int32_t, int32_t); template void fj_bin_patch_row(const int32_t*, @@ -554,7 +545,6 @@ template void fj_bin_patch_row(const int32_t*, const int32_t*, int32_t, int32_t, - int32_t, int32_t); void fj_bin_argmax(const int32_t* var_score, From b0c192429c32bd47ccfee61084c70763330aca06 Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Fri, 7 Aug 2026 08:29:06 -0700 Subject: [PATCH 27/78] refactor: replace the row record with a plain weight array Once the sign was normalized away, fj_bin_row_t was a single int32 field in a struct still templated on a coef_t it never used. It is now a std::vector row_weight on the engine, which drops the type, its template parameter, and the reference-into-the-array idiom at every use site. Storage is unchanged -- four bytes per row either way -- and the trajectory is bit-identical: 88030 common (climber, iteration) samples across all 16 climbers match on viol, best and maxw. This costs 0.57% on supportcase22: 4.400 -> 4.375 M iters/s, both means of four runs measured back to back to rule out machine drift. The cause is not in the work done. apply_move comes out smaller and no busier -- 822 instructions against 832, 37 spill stores against 38, identical reload and branch counts -- and the diff is a register allocation and stack slot reshuffle across the whole function rather than anything localized. Same class of second-order codegen effect as the ArgmaxImpl relocation that cost 123 cycles per iteration earlier with a byte-identical instruction stream. Taken deliberately: the simpler type is worth more than 0.5% here. Signed-off-by: Alice Boucher --- .../feasibility_jump/fj_cpu_binary.cu | 56 ++++++++----------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index f43a37aec0..49829ba59f 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -146,19 +146,6 @@ struct fj_bin_tabu_t { } }; -// What the apply path still needs per row once the row walk is vectorized: everything else it used -// to read from here now reaches it at unit stride. -// -// The mutable state moved out to the engine's row_slack, which holds bound - lhs rather than lhs, -// because that is what turns the walk's update into a subtraction and lets it gather one array -// instead of four. bound went with it -- only the rebuild paths need it, and they read pb.bound. -// cmax went to pb.incident_row_cmax, replicated per incidence. Every row is stored as a'x <= b, so -// there is no sign to carry. Only the weight is left, and it is genuinely mutable. -template -struct fj_bin_row_t { - int32_t weight; -}; - // Narrowed problem: one-sided rows, integer coefficients, CSR plus its transpose. template struct fj_bin_problem_t { @@ -453,7 +440,10 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, template struct fj_bin_engine_t { fj_bin_problem_t pb; - std::vector> rows; + // The only mutable per-row state besides the slack. Everything else the apply path once read + // per row now reaches it at unit stride: bound stayed in pb, where only the rebuild paths need + // it, and cmax went to pb.incident_row_cmax, replicated per incidence. + std::vector row_weight; // Per row, bound - lhs: negative exactly when the row is violated, and moved by a flip by exactly // -reverse_coefficients. The only mutable state the vectorized walk gathers. @@ -577,11 +567,11 @@ struct fj_bin_engine_t { const int8_t flip = (int8_t)(1 - 2 * assign[v]); int32_t agg_base = 0, agg_bonus = 0; for (int32_t i = pb.reverse_offsets[v]; i < pb.reverse_offsets[v + 1]; ++i) { - const fj_bin_row_t& h = rows[pb.reverse_constraints[i]]; - const int32_t os = row_slack[pb.reverse_constraints[i]]; + const int32_t r = pb.reverse_constraints[i]; + const int32_t os = row_slack[r]; const int32_t ns = os - (int32_t)pb.reverse_coefficients[i] * flip; int32_t base = 0, bonus = 0; - fj_bin_score_delta_parts(os, ns, h.weight, base, bonus); + fj_bin_score_delta_parts(os, ns, row_weight[r], base, bonus); agg_base += base; agg_bonus += bonus; } @@ -620,7 +610,7 @@ struct fj_bin_engine_t { { std::fill(var_score.begin(), var_score.end(), 0); for (int32_t r = 0; r < pb.n_constraints; ++r) { - const int32_t weight = rows[r].weight; + const int32_t weight = row_weight[r]; const int32_t os = row_slack[r]; for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { const int32_t v = pb.variables[k]; @@ -686,7 +676,7 @@ struct fj_bin_engine_t { // The tail writes a score delta through int32_t* and calls out to the patch, either of which may // alias a vector's internal pointer as far as the compiler can prove. Without these locals it // reloads every base pointer below out of `this` on each visit. - fj_bin_row_t* const rows_p = rows.data(); + int32_t* const weight_p = row_weight.data(); int32_t* const slack_p = row_slack.data(); const int32_t* const rcon_p = pb.reverse_constraints.data(); const coef_t* const skv_p = pb.reverse_coefficients.data(); @@ -703,7 +693,7 @@ struct fj_bin_engine_t { // so the walk's shape is the only thing that differs between them. auto finish = [&](int32_t ii) { const int32_t r = rcon_p[ii]; - fj_bin_row_t& h = rows_p[r]; + const int32_t weight = weight_p[r]; const int32_t skv = (int32_t)skv_p[ii]; const int32_t new_slack = slack_p[r]; const int32_t old_slack = new_slack + skv * delta; @@ -730,7 +720,7 @@ struct fj_bin_engine_t { var_score_p, nnz_delta_p, assign_p, - h.weight, + weight, new_slack, var); nnz_touched += ke - kb; @@ -739,7 +729,7 @@ struct fj_bin_engine_t { // The flipped variable's own score delta. Zero on the rows the walk absorbed -- deeply // satisfied both ways -- and already stored as zero there. - const int32_t pv = fj_bin_packed_score_delta(new_slack, new_slack - skv * new_flip, h.weight); + const int32_t pv = fj_bin_packed_score_delta(new_slack, new_slack - skv * new_flip, weight); own_score += pv; nnz_delta_p[rcsr_p[ii]] = pv; }; @@ -804,9 +794,8 @@ struct fj_bin_engine_t { void reweight_constraint(int32_t r, int32_t new_weight) { - fj_bin_row_t& h = rows[r]; - if (new_weight == h.weight) return; - h.weight = new_weight; + if (new_weight == row_weight[r]) return; + row_weight[r] = new_weight; if (new_weight > max_weight) max_weight = new_weight; // The slack is unchanged here, and no variable is excluded, so skip_var matches no index. const int32_t kb = pb.offsets[r], ke = pb.offsets[r + 1]; @@ -817,7 +806,7 @@ struct fj_bin_engine_t { var_score.data(), nnz_score_delta.data(), assign_i32.data(), - h.weight, + new_weight, row_slack[r], -1); nnz_touched += ke - kb; @@ -829,7 +818,7 @@ struct fj_bin_engine_t { void update_weights() { for (int32_t cf : violated_list) { - reweight_constraint(cf, rows[cf].weight + fj_bin_ddfw_transfer); + reweight_constraint(cf, row_weight[cf] + fj_bin_ddfw_transfer); const int32_t vo = pb.offsets[cf], ve = pb.offsets[cf + 1]; if (ve <= vo) continue; int32_t best_donor = -1, best_w = fj_bin_ddfw_init; @@ -839,12 +828,13 @@ struct fj_bin_engine_t { if (ne <= no) continue; const int32_t d = pb.reverse_constraints[no + (int32_t)(rng.next_u32() % (uint32_t)(ne - no))]; - if (d != cf && !is_violated[d] && rows[d].weight > best_w) { - best_w = rows[d].weight; + if (d != cf && !is_violated[d] && row_weight[d] > best_w) { + best_w = row_weight[d]; best_donor = d; } } - if (best_donor >= 0) reweight_constraint(best_donor, rows[best_donor].weight - fj_bin_ddfw_transfer); + if (best_donor >= 0) + reweight_constraint(best_donor, row_weight[best_donor] - fj_bin_ddfw_transfer); } if (violated_list.empty()) objective_weight += 1; } @@ -983,7 +973,7 @@ struct fj_bin_engine_t { { assign = seed_assign; for (int32_t v = 0; v < pb.n_variables; ++v) assign_i32[v] = assign[v]; - for (int32_t r = 0; r < pb.n_constraints; ++r) rows[r].weight = pb.initial_weight[r]; + for (int32_t r = 0; r < pb.n_constraints; ++r) row_weight[r] = pb.initial_weight[r]; max_weight = fj_bin_ddfw_init; objective_weight = 0; tabu.clear(iters); @@ -1025,9 +1015,7 @@ struct fj_bin_engine_t { assign_i32.assign(n, 0); for (int32_t v = 0; v < n; ++v) assign_i32[v] = assign[v]; - rows.resize(m); - for (int32_t r = 0; r < m; ++r) - rows[r] = fj_bin_row_t{pb.initial_weight[r]}; + row_weight.assign(pb.initial_weight.begin(), pb.initial_weight.end()); row_slack.assign(m, 0); var_score.assign(n, 0); From 2f63ab861c1140b41197e4d46477884c70f1e107 Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Fri, 7 Aug 2026 09:39:03 -0700 Subject: [PATCH 28/78] fix: derive the argmax tile from the L1 data cache size The tile width of the argmax sweep was ported from the prototype as a bare constant, dropping the residency guard that computed it. The target of 256 is about the shape of the sweep -- it sets how often the running maximum is raised, which is what bounds the index re-scan -- but the re-scan only pays off because it revisits a tile that is still L1-hot. On a small L1 an unguarded 256 pushes that re-read out to L2 and costs more than the split saves, which is what the cap the prototype applied was for. Bytes per variable is the score array alone. The prototype also counted its u16 flip_until, because its sweep tested tabu per lane; this engine blocks the handful of tabu variables at the invalid sentinel before the sweep and restores after, so flip_until is never touched here and the divisor is 4 rather than 6. No behaviour change on the machines we run: at 32 KiB the cap is 2048 and the target binds. supportcase22, 20s x 16 climbers, mean of 3: 4.375 -> 4.364 M iters/s, within the layout noise seen across today's commits, with 1/16 crossed as before. Signed-off-by: Alice Boucher --- .../feasibility_jump/fj_cpu_binary.cu | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 49829ba59f..4954554263 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -203,6 +204,36 @@ static inline bool fj_bin_in_int32(double v) return v >= (double)INT32_MIN && v <= (double)INT32_MAX; } +// Tile width of the argmax sweep, in variables: min(algorithm target, L1-residency cap). +// +// The target is about the shape of the sweep rather than cache capacity -- it sets how often the +// running maximum is raised, which is what bounds the index re-scan -- and 256 is the measured +// optimum. The cap is a residency guard, and it is the reason this is not simply a constant: the +// re-scan pays off only because it revisits a tile that is still L1-hot, so the tile must not be +// wide enough to spill. It bites only on a small L1, where an unguarded 256 would push the re-scan +// out to L2 and cost more than the split saves. +// +// Bytes per variable is the score array alone. Tabu does not appear: the sweep reads var_score +// only, with the handful of tabu variables held at the invalid sentinel across it, so flip_until is +// never touched here. +constexpr int32_t fj_bin_argmax_tile_target = 256; +constexpr int32_t fj_bin_argmax_tile_cap_k = 4; + +static int32_t fj_bin_argmax_tile() +{ +#ifdef _SC_LEVEL1_DCACHE_SIZE + long l1 = sysconf(_SC_LEVEL1_DCACHE_SIZE); +#else + long l1 = 0; +#endif + if (l1 <= 0) l1 = 32768; // fallback: 32 KiB, the common x86 L1d + const int32_t bpv = (int32_t)sizeof(int32_t); + const int32_t cap = (int32_t)(l1 / (fj_bin_argmax_tile_cap_k * bpv)); + int32_t t = fj_bin_argmax_tile_target < cap ? fj_bin_argmax_tile_target : cap; + t &= ~15; // whole vectors + return t < 16 ? 16 : t; +} + // Width-independent eligibility scan over the climber's host mirrors. Mutates nothing. template static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) @@ -488,9 +519,8 @@ struct fj_bin_engine_t { int64_t nnz_patched{0}; int64_t rows_walked{0}; - // Tile width for the argmax sweep. Governs how often the running maximum is raised, which is - // what bounds the index re-scan, so it is about the shape of the sweep and not cache capacity. - int32_t argmax_tile{256}; + // Tile width for the argmax sweep, in variables. Set at init from fj_bin_argmax_tile(). + int32_t argmax_tile{fj_bin_argmax_tile_target}; // Settings read at solve entry, where the climber carries populated values. int32_t seed{0}; @@ -1026,6 +1056,7 @@ struct fj_bin_engine_t { violated_list.clear(); var_bitmap.assign(n, 0); + argmax_tile = fj_bin_argmax_tile(); objective_weight = 0; max_weight = fj_bin_ddfw_init; incumbent_objective = 0; From 9bcb1b79db1202537d69fe3b4198da825764a548 Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Fri, 7 Aug 2026 09:40:51 -0700 Subject: [PATCH 29/78] debug: add CUOPT_NO_BINFJ to force the general path The binary fast path and the general path are meant to search identically, so a divergence between them is a bug in the fast path. Setting this env var makes try_cpufj_binary_solve decline unconditionally, which is what turns that comparison into one command on an instance the fast path would otherwise take. Found its first bug already: on chromaticindex1024-7 the general path crosses 16/16 in ~30s with 65k iterations per climber, while the fast path runs 1M iterations per climber and crosses 0/16, never reaching a local minimum and so never engaging DDFW. The cause is packed-score saturation -- the engine's own report gives aggregate bonus 122880 against a limit of 16384 -- which corrupts the lexicographic order the packing encodes. Signed-off-by: Alice Boucher --- cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 4954554263..490bfb6946 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -1157,6 +1158,12 @@ bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, f_t time_limit, double work_unit_limit) { + // Escape hatch for A/B against the general path on an instance the fast path would take. The two + // paths are meant to search identically, so any divergence is a bug in this one; setting this is + // how that gets bisected without editing the eligibility scan. + static const bool disabled = std::getenv("CUOPT_NO_BINFJ") != nullptr; + if (disabled) return false; + const fj_bin_scan_t scan = fj_bin_scan(climber); if (scan.reject != fj_binary_reject_t::none) { CUOPT_LOG_DEBUG("%sCPUFJ binary fast path declined: %s (row %d, var %d)", From bd9c3834498757733e2b81913fd042fd4551544b Mon Sep 17 00:00:00 2001 From: Alice Boucher Date: Fri, 7 Aug 2026 10:57:57 -0700 Subject: [PATCH 30/78] fix: widen the packed staged score to int64 The packed score encodes the general path's lexicographic (base, bonus) comparison as one arithmetic comparison, and at 15 bits the bonus field was too narrow to do it. Both fields aggregate over the rows a variable appears in, so each is bounded by max_var_degree * max_weight -- unbounded at build time, since DDFW grows the weights. Where the bonus exceeded 2^14 it carried into the base, manufacturing improvement that was not there, so the argmax ranked moves by a corrupted key, no local minimum was ever detected, and DDFW never engaged. Measured aggregate bonus against the old 16384 limit: chromaticindex1024-7 122880 (7.5x), 30n20b8 binarized 66396 (4.1x), crypt16 20229 (1.23x), bnatt400_reduced 17611 (1.07x). Only supportcase22 stayed inside, at 5751. Shift becomes 32, so base has the whole int32 range before the encoding can break. The two fields are per-row where they are computed, so the kernel still evaluates them as int32 at full lane count and widens only for the pack; the pack, the previous value, the difference and the store back all stay in the vector, leaving the scalar lane loop at one add per nonzero as it was before. That loop is 38% of all cycles, so it is the wrong place for work: a first version that packed there instead cost 26.7% rather than 17.8%, and a variant that also encoded the per-nnz delta as two int16 fields to halve that array cost 31.5% -- the patch path is instruction-bound, not bandwidth-bound. chromaticindex1024-7 goes from 0/16 crossings to 16/16, first crossing at 0.13s against 23-33s on the general path, same objective 4. supportcase22 costs 17.8% (4.364 -> 3.589 M iters/s) and is the one instance whose trajectory is unchanged, which is what the saturation numbers predict. crypt16 11/16 and bnatt400_reduced 16/16 objective 1 both hold. Not explained: on supportcase22 two of sixteen climbers diverge from the int32 trajectory past iteration 4.25M (887 of 72338 sampled points); the other fourteen are bit-identical. Left open deliberately rather than dismissed. Signed-off-by: Alice Boucher --- .../feasibility_jump/fj_cpu_binary.cu | 64 +++++----- .../feasibility_jump/fj_cpu_binary.cuh | 28 +++-- .../fj_cpu_binary_kernels.cpp | 113 ++++++++++++------ 3 files changed, 127 insertions(+), 78 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 490bfb6946..b65a1d8bf9 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -105,9 +105,9 @@ struct fj_bin_tabu_t { // replace the scores of tabu'd variable with sentinel values int32_t block_tabu(int32_t iter, - int32_t* var_score, + int64_t* var_score, int32_t (&saved_var)[ring_size], - int32_t (&saved_score)[ring_size]) const + int64_t (&saved_score)[ring_size]) const { int32_t k = 0; for (int32_t i = 0; i < ring_size; ++i) { @@ -124,9 +124,9 @@ struct fj_bin_tabu_t { // reverse the above operation. static void unblock_tabu(int32_t k, - int32_t* var_score, + int64_t* var_score, const int32_t (&saved_var)[ring_size], - const int32_t (&saved_score)[ring_size]) + const int64_t (&saved_score)[ring_size]) { for (int32_t i = k - 1; i >= 0; --i) var_score[saved_var[i]] = saved_score[i]; } @@ -486,8 +486,8 @@ struct fj_bin_engine_t { std::vector seed_assign; // restart target std::vector assign_i32; // gather mirror for the SIMD patch (Batch B) - std::vector var_score; // live feasibility score of flipping each variable - std::vector nnz_score_delta; // per CSR nnz: last score delta of variables[k] in its row + std::vector var_score; // live feasibility score of flipping each variable + std::vector nnz_score_delta; // per CSR nnz: last score delta of variables[k] in its row fj_bin_tabu_t tabu; @@ -647,7 +647,7 @@ struct fj_bin_engine_t { const int32_t v = pb.variables[k]; const int32_t flip = 1 - 2 * assign[v]; const int32_t ns = os - (int32_t)pb.coefficients[k] * flip; - const int32_t p = fj_bin_packed_score_delta(os, ns, weight); + const int64_t p = fj_bin_packed_score_delta(os, ns, weight); nnz_score_delta[k] = p; var_score[v] += p; } @@ -673,7 +673,7 @@ struct fj_bin_engine_t { rebuild_scores(); } - int32_t objective_terms(int32_t v, int8_t delta) const + int64_t objective_terms(int32_t v, int8_t delta) const { const double obj_diff = pb.objective[v] * delta; const int32_t base = obj_diff < 0 ? objective_weight : (obj_diff > 0 ? -objective_weight : 0); @@ -685,10 +685,10 @@ struct fj_bin_engine_t { } else if (old_better && !new_better) { bonus -= objective_weight; } - return base * fj_bin_score_k + bonus; + return (int64_t)base * fj_bin_score_k + bonus; } - int32_t full_score(int32_t v, int8_t delta) const + int64_t full_score(int32_t v, int8_t delta) const { if (objective_weight == 0) return var_score[v]; return var_score[v] + objective_terms(v, delta); @@ -702,7 +702,7 @@ struct fj_bin_engine_t { const int8_t new_flip = (int8_t)(1 - 2 * new_val); const int32_t ob = pb.reverse_offsets[var], oe = pb.reverse_offsets[var + 1]; const int32_t prev_violated = (int32_t)violated_list.size(); - int32_t own_score = 0; + int64_t own_score = 0; // The tail writes a score delta through int32_t* and calls out to the patch, either of which may // alias a vector's internal pointer as far as the compiler can prove. Without these locals it @@ -716,8 +716,8 @@ struct fj_bin_engine_t { const int32_t* const offsets_p = pb.offsets.data(); const int32_t* const vars_p = pb.variables.data(); const coef_t* const coefs_p = pb.coefficients.data(); - int32_t* const var_score_p = var_score.data(); - int32_t* const nnz_delta_p = nnz_score_delta.data(); + int64_t* const var_score_p = var_score.data(); + int64_t* const nnz_delta_p = nnz_score_delta.data(); const int32_t* const assign_p = assign_i32.data(); // Everything a visit still needs once its slack has been advanced. Shared by the two arms below @@ -760,7 +760,7 @@ struct fj_bin_engine_t { // The flipped variable's own score delta. Zero on the rows the walk absorbed -- deeply // satisfied both ways -- and already stored as zero there. - const int32_t pv = fj_bin_packed_score_delta(new_slack, new_slack - skv * new_flip, weight); + const int64_t pv = fj_bin_packed_score_delta(new_slack, new_slack - skv * new_flip, weight); own_score += pv; nnz_delta_p[rcsr_p[ii]] = pv; }; @@ -873,25 +873,28 @@ struct fj_bin_engine_t { // Global argmax over every variable, affordable because var_score is maintained live. While the // objective weight is zero the full score is exactly var_score, which is the vectorized sweep's // precondition; the objective and local-minimum paths fall to the scalar loop. - std::pair find_move_global(bool localmin) + std::pair find_move_global(bool localmin) { if (!localmin && objective_weight == 0) { // The sweep reads var_score alone; the handful of tabu variables are held at the invalid // sentinel across it rather than tested per variable. - int32_t saved_var[fj_bin_tabu_t::ring_size], saved_score[fj_bin_tabu_t::ring_size]; + int32_t saved_var[fj_bin_tabu_t::ring_size]; + int64_t saved_score[fj_bin_tabu_t::ring_size]; const int32_t blocked = tabu.block_tabu(iters, var_score.data(), saved_var, saved_score); - int32_t v = -1, s = fj_bin_score_invalid; + int32_t v = -1; + int64_t s = fj_bin_score_invalid; fj_bin_argmax(var_score.data(), pb.n_variables, argmax_tile, v, s); fj_bin_tabu_t::unblock_tabu(blocked, var_score.data(), saved_var, saved_score); return {v, s}; } - int32_t best_v = -1, best_s = fj_bin_score_invalid; + int32_t best_v = -1; + int64_t best_s = fj_bin_score_invalid; for (int32_t v = 0; v < pb.n_variables; ++v) { if (tabu_blocked(v, localmin)) continue; - const int32_t s = full_score(v, (int8_t)(1 - 2 * assign[v])); + const int64_t s = full_score(v, (int8_t)(1 - 2 * assign[v])); if (s > best_s) { best_s = s; best_v = v; @@ -900,17 +903,18 @@ struct fj_bin_engine_t { return {best_v, best_s}; } - std::pair find_move_in_rows(const std::vector& target_rows, + std::pair find_move_in_rows(const std::vector& target_rows, bool localmin) { - int32_t best_v = -1, best_s = fj_bin_score_invalid; + int32_t best_v = -1; + int64_t best_s = fj_bin_score_invalid; for (int32_t r : target_rows) { for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { const int32_t v = pb.variables[k]; if (var_bitmap[v]) continue; var_bitmap[v] = 1; if (tabu_blocked(v, localmin)) continue; - const int32_t s = full_score(v, (int8_t)(1 - 2 * assign[v])); + const int64_t s = full_score(v, (int8_t)(1 - 2 * assign[v])); if (s > best_s) { best_s = s; best_v = v; @@ -925,7 +929,7 @@ struct fj_bin_engine_t { return {best_v, best_s}; } - std::pair find_move_violated(int32_t sample_size, bool localmin) + std::pair find_move_violated(int32_t sample_size, bool localmin) { // Draw the rows directly instead of reservoir-sampling the violated list: `std::sample` is // linear in the population, so it walked every violated row to keep a handful. Sampling with @@ -952,14 +956,14 @@ struct fj_bin_engine_t { if (target > 1) target = 1; if ((int8_t)target == assign[v]) continue; if (tabu_blocked(v, false)) continue; - const int32_t s = full_score(v, (int8_t)((int8_t)target - assign[v])); + const int64_t s = full_score(v, (int8_t)((int8_t)target - assign[v])); if (s > move.second) move = {v, s}; } } return move; } - std::pair find_move_satisfied(int32_t sample_size) + std::pair find_move_satisfied(int32_t sample_size) { sample_buf.clear(); for (int32_t tries = 0; (int32_t)sample_buf.size() < sample_size && tries < sample_size * 8; @@ -970,14 +974,15 @@ struct fj_bin_engine_t { return find_move_in_rows(sample_buf, false); } - std::pair find_lift_move() const + std::pair find_lift_move() const { - int32_t best_v = -1, best_s = 0; + int32_t best_v = -1; + int64_t best_s = 0; for (int32_t v : pb.objective_vars) { const int8_t delta = (int8_t)(1 - 2 * assign[v]); if ((double)delta * pb.objective[v] >= 0) continue; if (tabu_blocked(v, false)) continue; - const int32_t s = (int32_t)(-std::llround(pb.objective[v] * delta)) * fj_bin_score_k; + const int64_t s = (int64_t)(-std::llround(pb.objective[v] * delta)) * fj_bin_score_k; if (s > best_s) { best_s = s; best_v = v; @@ -1083,7 +1088,8 @@ struct fj_bin_engine_t { if (iters - last_restart_iter >= fj_bin_restart_period) do_restart(); tabu.maybe_rebase(iters); - int32_t move_var = -1, score = fj_bin_score_invalid; + int32_t move_var = -1; + int64_t score = fj_bin_score_invalid; if (violated_list.empty()) std::tie(move_var, score) = find_lift_move(); if (score <= 0) std::tie(move_var, score) = find_move_global(false); if (feasible_found && score <= 0) std::tie(move_var, score) = find_move_satisfied(mtm_sat_samples); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh index 9b45af515d..524ead8780 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh @@ -39,10 +39,18 @@ bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, f_t time_limit, double work_unit_limit); -// Packed staged score: one int32 holding base * K + bonus -constexpr int32_t fj_bin_score_shift = 15; -constexpr int32_t fj_bin_score_k = 1 << fj_bin_score_shift; -constexpr int32_t fj_bin_score_invalid = INT32_MIN; +// Packed staged score: one int64 holding base * K + bonus, encoding the general path's +// lexicographic (base, bonus) comparison as a single arithmetic one. +// +// The width is what makes the encoding faithful. Both fields aggregate over the rows a variable +// appears in, so each is bounded by max_var_degree * max_weight -- unbounded above at build time, +// since DDFW grows the weights. At 15 bits the bonus field overflowed into the base on real +// instances (chromaticindex1024-7 reaches an aggregate bonus of 122880 against 16384), which +// silently corrupts the ordering the argmax depends on. 32 bits leaves the base free to use the +// whole int32 range before the encoding can break. +constexpr int32_t fj_bin_score_shift = 32; +constexpr int64_t fj_bin_score_k = (int64_t)1 << fj_bin_score_shift; +constexpr int64_t fj_bin_score_invalid = INT64_MIN; // Change in one row's weighted score when one variable flips, from the row's signed slack before // (os) and after (ns) that flip. base is the weighted change in satisfaction; bonus is the @@ -59,11 +67,11 @@ static inline void fj_bin_score_delta_parts( bonus = weight * (nst - ost); } -static inline int32_t fj_bin_packed_score_delta(int32_t os, int32_t ns, int32_t weight) +static inline int64_t fj_bin_packed_score_delta(int32_t os, int32_t ns, int32_t weight) { int32_t base = 0, bonus = 0; fj_bin_score_delta_parts(os, ns, weight, base, bonus); - return base * fj_bin_score_k + bonus; + return (int64_t)base * fj_bin_score_k + bonus; } // Padding margin to prevent faults on tail SIMD loads @@ -77,8 +85,8 @@ void fj_bin_patch_row(const int32_t* variables, const coef_t* coefficients, int32_t kb, int32_t ke, - int32_t* var_score, - int32_t* nnz_score_delta, + int64_t* var_score, + int64_t* nnz_score_delta, const int32_t* assign_i32, int32_t weight, int32_t os_new, @@ -107,10 +115,10 @@ int32_t fj_bin_walk_rows(int32_t* row_slack, // the full score is exactly var_score. Yields best_var of -1 only if n is 0. // Tabu is handled by "blocking" the scores corresponding to the tabu vars, and restoring them after the argmax // affordable since max_tenure is small -void fj_bin_argmax(const int32_t* var_score, +void fj_bin_argmax(const int64_t* var_score, int32_t n, int32_t tile, int32_t& best_var, - int32_t& best_score); + int64_t& best_score); } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp index f2da24c580..915a98f9d4 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp @@ -144,8 +144,8 @@ void PatchRowScalar(const int32_t* HWY_RESTRICT variables, const coef_t* HWY_RESTRICT coefficients, int32_t kb, int32_t ke, - int32_t* HWY_RESTRICT var_score, - int32_t* HWY_RESTRICT nnz_score_delta, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, int32_t weight, int32_t os_new, @@ -156,7 +156,7 @@ void PatchRowScalar(const int32_t* HWY_RESTRICT variables, if (v == skip_var) continue; const int32_t flip = 1 - 2 * assign_i32[v]; const int32_t ns = os_new - (int32_t)coefficients[k] * flip; - const int32_t nc = fj_bin_packed_score_delta(os_new, ns, weight); + const int64_t nc = fj_bin_packed_score_delta(os_new, ns, weight); var_score[v] += nc - nnz_score_delta[k]; nnz_score_delta[k] = nc; } @@ -171,16 +171,20 @@ static HWY_INLINE void PatchRowBody(D d, const coef_t* HWY_RESTRICT coefficients, int32_t kb, int32_t ke, - int32_t* HWY_RESTRICT var_score, - int32_t* HWY_RESTRICT nnz_score_delta, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, int32_t weight, int32_t os_new, int32_t skip_var) { - const hn::Rebind dc; // same lane count, narrower lanes - using V = hn::Vec; - const size_t N = hn::Lanes(d); + const hn::Rebind dc; // same lane count, narrower lanes + const hn::Repartition dw; // half the lanes, twice as wide: the packed score + const hn::Half dh; // int32 half, the source of each promotion + using V = hn::Vec; + using VW = hn::Vec; + const size_t N = hn::Lanes(d); + const size_t NW = hn::Lanes(dw); // When the remainder is peeled, a row below one vector never reaches the body, so it skips the // ten broadcasts below as well. @@ -231,17 +235,45 @@ static HWY_INLINE void PatchRowBody(D d, const V both_violated = v_not_osat * (vone - nsat); const V base = vw * (nsat - vosat) + both_violated * improving * vw2; const V bonus = vw * (nst - vost); - const V packed_new = hn::ShiftLeft(base) + bonus; - const V delta = packed_new - hn::LoadU(d, nnz_score_delta + k); + // The score is int64, so packing it costs two vectors where the fields took one. Both fields + // are per-row here and fit int32, so they are computed at full lane count above and widened + // only for the pack. Everything below stays in the vector: the pack, the old value, the + // difference and the store back. What reaches the scalar loop is one add per nonzero, which is + // what it was before the score widened -- that loop is 38% of all cycles, so work belongs + // anywhere but there. + const VW base_lo = hn::PromoteTo(dw, hn::LowerHalf(dh, base)); + const VW base_hi = hn::PromoteTo(dw, hn::UpperHalf(dh, base)); + const VW bonus_lo = hn::PromoteTo(dw, hn::LowerHalf(dh, bonus)); + const VW bonus_hi = hn::PromoteTo(dw, hn::UpperHalf(dh, bonus)); + + const VW packed_lo = hn::ShiftLeft(base_lo) + bonus_lo; + const VW packed_hi = hn::ShiftLeft(base_hi) + bonus_hi; + + const VW delta_lo = packed_lo - hn::LoadU(dw, nnz_score_delta + k); + const VW delta_hi = packed_hi - hn::LoadU(dw, nnz_score_delta + k + NW); + + // The store mask is rebuilt at int64 width rather than narrowed from `active`: the same two + // conditions, on the promoted indices. FirstN is applied on every target because where the + // remainder is peeled the body never runs short, so it is all-true there anyway. + const size_t rem = (size_t)(ke - k); + const VW v_lo = hn::PromoteTo(dw, hn::LowerHalf(dh, v)); + const VW v_hi = hn::PromoteTo(dw, hn::UpperHalf(dh, v)); + const VW vskip_w = hn::Set(dw, skip_var); + const auto act_lo = hn::And(hn::Ne(v_lo, vskip_w), hn::FirstN(dw, rem)); + const auto act_hi = hn::And(hn::Ne(v_hi, vskip_w), hn::FirstN(dw, rem > NW ? rem - NW : 0)); + hn::BlendedStore(packed_lo, act_lo, dw, nnz_score_delta + k); + hn::BlendedStore(packed_hi, act_hi, dw, nnz_score_delta + k + NW); #if HWY_TARGET == HWY_AVX3_ZEN4 // zmm VSIB is microcode on Zen 4: VPGATHERDD ~76-80 uops / ~21 CPI and VPSCATTERDD 89 / 24, // against ~5 / ~10 and ~19 / ~11 on SPR-class Intel (Agner Fog, uops.info). So read-modify-write // by lane here; measured +5.8% over the arm below on an EPYC 9554 (supportcase22, 16 climbers). - HWY_ALIGN int32_t idx[hn::MaxLanes(d)], dl[hn::MaxLanes(d)]; + HWY_ALIGN int32_t idx[hn::MaxLanes(d)]; + HWY_ALIGN int64_t dl[hn::MaxLanes(d)]; hn::Store(v, d, idx); - hn::Store(delta, d, dl); + hn::Store(delta_lo, dw, dl); + hn::Store(delta_hi, dw, dl + NW); // Bounded by the row, not the vector: the lanes past it hold padding, whose zero index would // otherwise be applied to variable 0. const size_t lanes = HWY_MIN(N, (size_t)(ke - k)); @@ -249,11 +281,13 @@ static HWY_INLINE void PatchRowBody(D d, if (idx[i] != skip_var) var_score[idx[i]] += dl[i]; } #else - const V current = hn::MaskedGatherIndex(active, d, var_score, v); - hn::MaskedScatterIndex(current + delta, active, d, var_score, v); + // The score is int64, so the gather and scatter run at the promoted width against the promoted + // indices, in the two halves the pack already produced. + const VW cur_lo = hn::MaskedGatherIndex(act_lo, dw, var_score, v_lo); + const VW cur_hi = hn::MaskedGatherIndex(act_hi, dw, var_score, v_hi); + hn::MaskedScatterIndex(cur_lo + delta_lo, act_lo, dw, var_score, v_lo); + hn::MaskedScatterIndex(cur_hi + delta_hi, act_hi, dw, var_score, v_hi); #endif - - hn::BlendedStore(packed_new, active, d, nnz_score_delta + k); } if constexpr (!k_mask_remainder) { @@ -268,8 +302,8 @@ void PatchRowImpl(const int32_t* HWY_RESTRICT variables, const coef_t* HWY_RESTRICT coefficients, int32_t kb, int32_t ke, - int32_t* HWY_RESTRICT var_score, - int32_t* HWY_RESTRICT nnz_score_delta, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, int32_t weight, int32_t os_new, @@ -284,8 +318,8 @@ void PatchRowNarrow8Impl(const int32_t* HWY_RESTRICT variables, const coef_t* HWY_RESTRICT coefficients, int32_t kb, int32_t ke, - int32_t* HWY_RESTRICT var_score, - int32_t* HWY_RESTRICT nnz_score_delta, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, int32_t weight, int32_t os_new, @@ -300,8 +334,8 @@ void PatchRowNarrow4Impl(const int32_t* HWY_RESTRICT variables, const coef_t* HWY_RESTRICT coefficients, int32_t kb, int32_t ke, - int32_t* HWY_RESTRICT var_score, - int32_t* HWY_RESTRICT nnz_score_delta, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, int32_t weight, int32_t os_new, @@ -337,8 +371,8 @@ void PatchRowDispatchImpl(const int32_t* HWY_RESTRICT variables, const coef_t* HWY_RESTRICT coefficients, int32_t kb, int32_t ke, - int32_t* HWY_RESTRICT var_score, - int32_t* HWY_RESTRICT nnz_score_delta, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, const int32_t* HWY_RESTRICT assign_i32, int32_t weight, int32_t os_new, @@ -360,13 +394,13 @@ void PatchRowDispatchImpl(const int32_t* HWY_RESTRICT variables, // Tiled sweep carrying a running maximum. The index re-scan fires only on a tile that raises it, // and that tile is still cache-hot. The tabu window is uint16 against int32 scores, so the mask // crosses a 2:1 width boundary through PromoteMaskTo. -void ArgmaxImpl(const int32_t* HWY_RESTRICT var_score, +void ArgmaxImpl(const int64_t* HWY_RESTRICT var_score, int32_t n, int32_t tile, int32_t* best_var, - int32_t* best_score) + int64_t* best_score) { - const hn::ScalableTag d; + const hn::ScalableTag d; using V = hn::Vec; const int32_t step = (int32_t)hn::Lanes(d); @@ -377,7 +411,8 @@ void ArgmaxImpl(const int32_t* HWY_RESTRICT var_score, int32_t tile_step = tile - (tile % step); if (tile_step < step) tile_step = step; - int32_t bv = -1, bs = fj_bin_score_invalid; + int32_t bv = -1; + int64_t bs = fj_bin_score_invalid; for (int32_t t0 = 0; t0 < nblk; t0 += tile_step) { const int32_t t1 = (t0 + tile_step < nblk) ? t0 + tile_step : nblk; @@ -387,7 +422,7 @@ void ArgmaxImpl(const int32_t* HWY_RESTRICT var_score, tile_max = hn::Max(tile_max, hn::LoadU(d, var_score + v)); } - const int32_t peak = hn::ReduceMax(d, tile_max); + const int64_t peak = hn::ReduceMax(d, tile_max); if (peak > bs) { const V vpeak = hn::Set(d, peak); for (int32_t v = t0; v < t1; v += step) { @@ -452,8 +487,8 @@ using fj_bin_patch_fn_t = void (*)(const int32_t*, const coef_t*, int32_t, int32_t, - int32_t*, - int32_t*, + int64_t*, + int64_t*, const int32_t*, int32_t, int32_t, @@ -514,8 +549,8 @@ void fj_bin_patch_row(const int32_t* variables, const coef_t* coefficients, int32_t kb, int32_t ke, - int32_t* var_score, - int32_t* nnz_score_delta, + int64_t* var_score, + int64_t* nnz_score_delta, const int32_t* assign_i32, int32_t weight, int32_t os_new, @@ -529,8 +564,8 @@ template void fj_bin_patch_row(const int32_t*, const int8_t*, int32_t, int32_t, - int32_t*, - int32_t*, + int64_t*, + int64_t*, const int32_t*, int32_t, int32_t, @@ -540,18 +575,18 @@ template void fj_bin_patch_row(const int32_t*, const int16_t*, int32_t, int32_t, - int32_t*, - int32_t*, + int64_t*, + int64_t*, const int32_t*, int32_t, int32_t, int32_t); -void fj_bin_argmax(const int32_t* var_score, +void fj_bin_argmax(const int64_t* var_score, int32_t n, int32_t tile, int32_t& best_var, - int32_t& best_score) + int64_t& best_score) { fj_bin_argmax_fn(var_score, n, tile, &best_var, &best_score); } From c308ce496594e1c6778fbe0bfce91815f1d9bbf2 Mon Sep 17 00:00:00 2001 From: yboucher Date: Tue, 18 Aug 2026 13:51:49 -0700 Subject: [PATCH 31/78] add row integralize --- .../feasibility_jump/fj_cpu_binary.cu | 76 ++++++++++++++----- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index b65a1d8bf9..fe41614cbb 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -12,6 +12,7 @@ #include #include +#include #include @@ -184,8 +185,11 @@ struct fj_bin_scan_t { int32_t n_split_constraints{0}; int32_t bad_row{-1}; int32_t bad_var{-1}; + std::vector row_scale; }; +constexpr int64_t fj_bin_scale_cap = std::numeric_limits::max(); + // DDFW and restart have no general-path equivalent, so their defaults live here until there is a // reason to promote them alongside the other FJ knobs. constexpr int32_t fj_bin_ddfw_init = 10; // initial weight, also the donation floor @@ -266,15 +270,54 @@ static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) const auto& cstr_ub = c.h_cstr_ub; double max_abs_coefficient = 0; + std::vector row_values; for (int32_t r = 0; r < m; ++r) { - double row_abs_sum = 0; + const double lb = cstr_lb[r]; + const double ub = cstr_ub[r]; + const bool lb_fin = std::isfinite(lb); + const bool ub_fin = std::isfinite(ub); + const double sides[2] = {lb, ub}; + const bool finite[2] = {lb_fin, ub_fin}; + + bool fractional_coefficient_seen = false; + bool integral = true; for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { - const double a = coeffs[k]; - if (!is_integer(a, tol)) { - out.reject = fj_binary_reject_t::fractional_coefficient; + if (!is_integer(coeffs[k], tol)) { + fractional_coefficient_seen = true; + integral = false; + break; + } + } + for (int s = 0; s < 2 && integral; ++s) { + if (finite[s] && !is_integer(sides[s], tol)) integral = false; + } + + double row_s = 1.0; + if (!integral) { + row_values.clear(); + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) row_values.push_back(coeffs[k]); + for (int s = 0; s < 2; ++s) { + if (finite[s]) row_values.push_back(sides[s]); + } + row_s = find_scaling_rational(row_values, + /*maxscale=*/1.0 / tol, + /*maxdnom=*/fj_bin_scale_cap, + /*maxfinal=*/(double)fj_bin_scale_cap, + /*intcheck_tol=*/tol); + if (!std::isfinite(row_s) || row_s <= 0.0) { + out.reject = fractional_coefficient_seen ? fj_binary_reject_t::fractional_coefficient + : fj_binary_reject_t::fractional_row_bound; out.bad_row = r; return out; } + if (out.row_scale.empty()) out.row_scale.assign(m, 1.0); + out.row_scale[r] = row_s; + } + + double row_abs_sum = 0; + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { + const double a = row_s * coeffs[k]; + cuopt_assert(is_integer(a, tol), "row scaling left a fractional coefficient"); const double abs_a = std::fabs(std::round(a)); row_abs_sum += abs_a; if (abs_a > max_abs_coefficient) max_abs_coefficient = abs_a; @@ -288,20 +331,11 @@ static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) return out; } - const double lb = cstr_lb[r]; - const double ub = cstr_ub[r]; - const bool lb_fin = std::isfinite(lb); - const bool ub_fin = std::isfinite(ub); - const double sides[2] = {lb, ub}; - const bool finite[2] = {lb_fin, ub_fin}; for (int s = 0; s < 2; ++s) { if (!finite[s]) continue; - if (!is_integer(sides[s], tol)) { - out.reject = fj_binary_reject_t::fractional_row_bound; - out.bad_row = r; - return out; - } - if (!fj_bin_in_int32(std::round(sides[s]))) { + const double scaled_side = row_s * sides[s]; + cuopt_assert(is_integer(scaled_side, tol), "row scaling left a fractional row bound"); + if (!fj_bin_in_int32(std::round(scaled_side))) { out.reject = fj_binary_reject_t::row_bound_out_of_range; out.bad_row = r; return out; @@ -330,9 +364,10 @@ static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) // failing check here is a self-consistency bug and refuses the fast path rather than truncating. template static bool fj_bin_narrow(const fj_cpu_climber_t& c, - int32_t n_split, + const fj_bin_scan_t& scan, fj_bin_problem_t& pb) { + const int32_t n_split = scan.n_split_constraints; const int32_t n = c.view.pb.n_variables; const int32_t m = c.view.pb.n_constraints; const double tol = c.view.pb.tolerances.integrality_tolerance; @@ -366,9 +401,10 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, // fields: the scan admits |coef| up to 127 for int8 and 32767 for int16, and the bound is checked // for int32 range after negating. auto emit = [&](int32_t r, double side_bound, long side, double weight) -> bool { + const double s = scan.row_scale.empty() ? 1.0 : scan.row_scale[r]; coef_t row_cmax = 1; for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { - const double a = coeffs[k]; + const double a = s * coeffs[k]; const long ai = side * std::lround(a); if (!is_integer(a, tol) || ai < std::numeric_limits::min() || ai > std::numeric_limits::max()) { @@ -379,7 +415,7 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, const coef_t abs_a = (coef_t)std::labs(ai); if (abs_a > row_cmax) row_cmax = abs_a; } - const long b = side * std::lround(side_bound); + const long b = side * std::lround(s * side_bound); if (!fj_bin_in_int32((double)b)) return false; pb.offsets.push_back((int32_t)pb.variables.size()); pb.bound.push_back((int32_t)b); @@ -1181,7 +1217,7 @@ bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, } auto run = [&](auto& engine) -> bool { - if (!fj_bin_narrow(climber, scan.n_split_constraints, engine.pb)) { + if (!fj_bin_narrow(climber, scan, engine.pb)) { CUOPT_LOG_DEBUG("%sCPUFJ binary fast path declined: %s", climber.log_prefix.c_str(), fj_binary_reject_name(fj_binary_reject_t::narrow_check_failed)); From 2f7cf86e632db279a592d4d13fba68ab1becd7c0 Mon Sep 17 00:00:00 2001 From: yboucher Date: Wed, 19 Aug 2026 01:09:47 -0700 Subject: [PATCH 32/78] fix solve_CPUFJ link --- cpp/CMakeLists.txt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 0fe2bf064b..72d03b3d2d 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -49,6 +49,7 @@ rapids_cmake_build_type(Release) option(CMAKE_CUDA_LINEINFO "Enable the -lineinfo option for nvcc useful for cuda-memcheck / profiler" ON) option(BUILD_TESTS "Configure CMake to build tests" ON) option(BUILD_LP_ONLY "Build only linear programming components, exclude routing and MIP-specific files" OFF) +option(BUILD_MIP_BENCHMARKS "Build MIP benchmarks" OFF) option(SKIP_C_PYTHON_ADAPTERS "Skip building C and Python adapter files (cython_solve.cu and cuopt_c.cpp)" OFF) option(SKIP_ROUTING_BUILD "Skip building routing components" OFF) option(SKIP_GRPC_BUILD "Skip building gRPC and protobuf components" OFF) @@ -800,6 +801,9 @@ target_link_libraries(cuopt_objs # - generate tests -------------------------------------------------------------------------------- if (BUILD_TESTS) include(CTest) +endif () + +if (BUILD_TESTS OR (BUILD_MIP_BENCHMARKS AND NOT BUILD_LP_ONLY)) add_library(cuopt_static STATIC $) target_link_libraries(cuopt_static PUBLIC @@ -842,6 +846,9 @@ if (BUILD_TESTS) if (TARGET KaMinPar) add_dependencies(cuopt_static KaMinPar) endif () +endif () + +if (BUILD_TESTS) add_subdirectory(tests) endif (BUILD_TESTS) @@ -1058,7 +1065,6 @@ if (NOT BUILD_LP_ONLY) endif () -option(BUILD_MIP_BENCHMARKS "Build MIP benchmarks" OFF) if (BUILD_MIP_BENCHMARKS AND NOT BUILD_LP_ONLY) add_executable(solve_MIP ../benchmarks/linear_programming/cuopt/run_mip.cpp) target_include_directories(solve_MIP @@ -1102,7 +1108,7 @@ if (BUILD_MIP_BENCHMARKS AND NOT BUILD_LP_ONLY) ) target_link_libraries(solve_CPUFJ PUBLIC - cuopt + cuopt_static OpenMP::OpenMP_CXX OpenMP::OpenMP_CUDA ) From 5805269d024f947ba6acf21df4dcc817e645b867 Mon Sep 17 00:00:00 2001 From: yboucher Date: Wed, 19 Aug 2026 01:14:10 -0700 Subject: [PATCH 33/78] latency tweaks --- .../cpu_optimization_problem.hpp | 7 + .../optimization_problem.hpp | 7 + cpp/src/mip_heuristics/early_heuristic.cuh | 64 +--- .../feasibility_jump/early_cpufj.cu | 14 +- .../feasibility_jump/early_cpufj.cuh | 7 + .../feasibility_jump/early_gpufj.cu | 41 ++- .../feasibility_jump/early_gpufj.cuh | 15 + .../mip_heuristics/feasibility_jump/fj_cpu.cu | 188 +++++++++- .../feasibility_jump/fj_cpu.cuh | 7 + .../presolve/semi_continuous.cu | 2 + cpp/src/mip_heuristics/solve.cu | 175 +++++----- cpp/src/pdlp/cpu_optimization_problem.cpp | 58 +++- cpp/src/pdlp/optimization_problem.cu | 47 ++- cpp/src/utilities/version_info.cpp | 323 +++++++++++------- 14 files changed, 668 insertions(+), 287 deletions(-) diff --git a/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp b/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp index 28aa91a82f..f0673a4f66 100644 --- a/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp +++ b/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp @@ -123,6 +123,12 @@ class cpu_optimization_problem_t : public optimization_problem_interface_t& get_variable_names() const override; const std::vector& get_row_names() const override; const std::vector& get_quadratic_objective_offsets() const override; @@ -208,6 +214,7 @@ class cpu_optimization_problem_t : public optimization_problem_interface_t std::string get_objective_name() const override; std::string get_problem_name() const override; problem_category_t get_problem_category() const override; + /** + * @brief Whether any variable type is SEMI_CONTINUOUS. + * + * Cached in set_variable_types(); used to skip SC reformulation host probes. + */ + bool has_semi_continuous_variables() const noexcept; const std::vector& get_variable_names() const override; const std::vector& get_row_names() const override; const std::vector& get_quadratic_objective_offsets() const override; @@ -391,6 +397,7 @@ class optimization_problem_t : public optimization_problem_interface_t rmm::cuda_stream_view stream_view_; problem_category_t problem_category_ = problem_category_t::LP; + bool has_semi_continuous_variables_{false}; bool maximize_{false}; i_t n_vars_{0}; i_t n_constraints_{0}; diff --git a/cpp/src/mip_heuristics/early_heuristic.cuh b/cpp/src/mip_heuristics/early_heuristic.cuh index 6654470732..84d5f86f7c 100644 --- a/cpp/src/mip_heuristics/early_heuristic.cuh +++ b/cpp/src/mip_heuristics/early_heuristic.cuh @@ -7,18 +7,13 @@ #pragma once -#include -#include - #include - -#include - -#include +#include #include #include #include +#include #include namespace cuopt::mathematical_optimization::mip { @@ -34,25 +29,13 @@ template class early_heuristic_t { public: early_heuristic_t(const optimization_problem_t& op_problem, - const typename mip_solver_settings_t::tolerances_t& tolerances, early_incumbent_callback_t incumbent_callback) - : incumbent_callback_(std::move(incumbent_callback)) + : objective_scaling_factor_(op_problem.get_sense() ? -op_problem.get_objective_scaling_factor() + : op_problem.get_objective_scaling_factor()), + objective_offset_(op_problem.get_sense() ? -op_problem.get_objective_offset() + : op_problem.get_objective_offset()), + incumbent_callback_(std::move(incumbent_callback)) { - RAFT_CUDA_TRY(cudaGetDevice(&device_id_)); - - // Build and preprocess on the original handle, then copy onto our own handle - // so the derived solver can run on a dedicated stream (prevents graph capture conflicts). - problem_t temp_problem(op_problem, tolerances, false); - temp_problem.preprocess_problem(); - temp_problem.handle_ptr->sync_stream(); - problem_ptr_ = std::make_unique>(temp_problem, &handle_); - - solution_ptr_ = std::make_unique>(*problem_ptr_); - thrust::fill(handle_.get_thrust_policy(), - solution_ptr_->assignment.begin(), - solution_ptr_->assignment.end(), - f_t{0}); - solution_ptr_->clamp_within_bounds(); } bool solution_found() const { return solution_found_; } @@ -60,12 +43,12 @@ class early_heuristic_t { // Return the best objective converted to user-space (sense-aware, offset-aware). f_t get_best_user_objective() const { - return problem_ptr_->get_user_obj_from_solver_obj(best_objective_); + return objective_scaling_factor_ * (best_objective_ + objective_offset_); } // Set the incumbent threshold. `obj` must be in THIS heuristic's solver-space - // (i.e. the space of problem_ptr_). Callers that hold a value from a different - // problem representation (e.g., the original pre-presolve problem) must convert - // it first, otherwise try_update_best will reject valid solutions. + // (i.e. the space of its input problem). Callers that hold a value from a + // different problem representation (e.g., the original pre-presolve problem) + // must convert it first, otherwise try_update_best will reject valid solutions. void set_best_objective(f_t obj) { best_objective_ = obj; } const std::vector& get_best_assignment() const { return best_assignment_; } @@ -73,40 +56,25 @@ class early_heuristic_t { ~early_heuristic_t() = default; // NOT thread-safe. solver_obj is in solver-space (always minimization). - // Uses a private CUDA stream to avoid racing with the FJ solver's stream. void try_update_best(f_t solver_obj, const std::vector& assignment) { if (solver_obj >= best_objective_) { return; } best_objective_ = solver_obj; - RAFT_CUDA_TRY(cudaSetDevice(device_id_)); - auto stream = handle_.get_stream(); - rmm::device_uvector d_assignment(assignment.size(), stream); - raft::copy(d_assignment.data(), assignment.data(), assignment.size(), stream); - problem_ptr_->post_process_assignment(d_assignment, true, stream); - auto user_assignment = cuopt::host_copy(d_assignment, stream); - - best_assignment_ = user_assignment; + best_assignment_ = ((Derived*)this)->to_user_assignment(assignment); solution_found_ = true; - f_t user_obj = problem_ptr_->get_user_obj_from_solver_obj(solver_obj); + f_t user_obj = get_best_user_objective(); // 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()); + incumbent_callback_(solver_obj, user_obj, best_assignment_, Derived::name()); } } - int device_id_{0}; - - // handle_ must be declared before problem_ptr_/solution_ptr_ so it outlives them - // (C++ destroys members in reverse declaration order) - raft::handle_t handle_; - - std::unique_ptr> problem_ptr_; - std::unique_ptr> solution_ptr_; - bool solution_found_{false}; f_t best_objective_{std::numeric_limits::infinity()}; + f_t objective_scaling_factor_; + f_t objective_offset_; std::vector best_assignment_; early_incumbent_callback_t incumbent_callback_; diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu index ba14e657d5..e4db66ed1f 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu @@ -16,8 +16,9 @@ early_cpufj_t::early_cpufj_t( const optimization_problem_t& op_problem, const typename mip_solver_settings_t::tolerances_t& tolerances, early_incumbent_callback_t incumbent_callback) - : early_heuristic_t>( - op_problem, tolerances, std::move(incumbent_callback)) + : early_heuristic_t>(op_problem, std::move(incumbent_callback)), + problem_ptr_(&op_problem), + tolerances_(tolerances) { } @@ -36,7 +37,8 @@ void early_cpufj_t::start() this->preemption_flag_.store(false); this->start_time_ = std::chrono::steady_clock::now(); - fj_cpu_ = init_fj_cpu_standalone(*this->problem_ptr_, *this->solution_ptr_, preemption_flag_); + fj_cpu_ = + init_fj_cpu_from_optimization_problem(*this->problem_ptr_, tolerances_, preemption_flag_); fj_cpu_->log_prefix = "[Early CPUFJ] "; @@ -67,6 +69,12 @@ void early_cpufj_t::stop() fj_cpu_.reset(); } +template +std::vector early_cpufj_t::to_user_assignment(const std::vector& assignment) +{ + return assignment; +} + #if MIP_INSTANTIATE_FLOAT template class early_cpufj_t; #endif diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh index e2bb2c07b2..a6cf3057e0 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh @@ -12,6 +12,7 @@ #include #include +#include namespace cuopt::mathematical_optimization::mip { @@ -30,6 +31,12 @@ class early_cpufj_t : public early_heuristic_t void stop(); private: + friend class early_heuristic_t>; + + std::vector to_user_assignment(const std::vector& assignment); + + const optimization_problem_t* problem_ptr_; + typename mip_solver_settings_t::tolerances_t tolerances_; std::unique_ptr> fj_cpu_; std::atomic preemption_flag_{false}; }; diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu index 463f074f59..c9d787a236 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu @@ -10,9 +10,15 @@ #include #include #include +#include #include #include +#include +#include + +#include +#include #include @@ -22,11 +28,26 @@ template early_gpufj_t::early_gpufj_t(const optimization_problem_t& op_problem, const mip_solver_settings_t& settings, early_incumbent_callback_t incumbent_callback) - : early_heuristic_t>( - op_problem, settings.get_tolerances(), std::move(incumbent_callback)) + : early_heuristic_t>(op_problem, std::move(incumbent_callback)) { - context_ptr_ = std::make_unique>( - &this->handle_, this->problem_ptr_.get(), settings); + RAFT_CUDA_TRY(cudaGetDevice(&device_id_)); + + // Build and preprocess on the original handle, then copy onto our own handle + // so the derived solver can run on a dedicated stream (prevents graph capture conflicts). + problem_t temp_problem(op_problem, settings.get_tolerances(), false); + temp_problem.preprocess_problem(); + temp_problem.handle_ptr->sync_stream(); + problem_ptr_ = std::make_unique>(temp_problem, &handle_); + + solution_ptr_ = std::make_unique>(*problem_ptr_); + thrust::fill(handle_.get_thrust_policy(), + solution_ptr_->assignment.begin(), + solution_ptr_->assignment.end(), + f_t{0}); + solution_ptr_->clamp_within_bounds(); + + context_ptr_ = + std::make_unique>(&handle_, problem_ptr_.get(), settings); } template @@ -81,6 +102,18 @@ void early_gpufj_t::stop() fj_ptr_.reset(); } +template +std::vector early_gpufj_t::to_user_assignment(const std::vector& assignment) +{ + // Uses a private CUDA stream to avoid racing with the FJ solver's stream. + RAFT_CUDA_TRY(cudaSetDevice(device_id_)); + auto stream = handle_.get_stream(); + rmm::device_uvector d_assignment(assignment.size(), stream); + raft::copy(d_assignment.data(), assignment.data(), assignment.size(), stream); + problem_ptr_->post_process_assignment(d_assignment, true, stream); + return cuopt::host_copy(d_assignment, stream); +} + #if MIP_INSTANTIATE_FLOAT template class early_gpufj_t; #endif diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cuh b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cuh index 99e8579d31..ed8d17206e 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cuh @@ -8,8 +8,11 @@ #pragma once #include +#include +#include #include +#include namespace cuopt::mathematical_optimization::mip { @@ -34,6 +37,18 @@ class early_gpufj_t : public early_heuristic_t void stop(); private: + friend class early_heuristic_t>; + + std::vector to_user_assignment(const std::vector& assignment); + + int device_id_{0}; + + // handle_ must be declared before problem_ptr_/solution_ptr_ so it outlives them + // (C++ destroys members in reverse declaration order) + raft::handle_t handle_; + + std::unique_ptr> problem_ptr_; + std::unique_ptr> solution_ptr_; std::unique_ptr> context_ptr_; std::unique_ptr> fj_ptr_; }; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index da009c13a6..4cce1df459 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -607,7 +607,6 @@ static inline std::pair compute_score(fj_cpu_climber_t& fj_cpu, auto [c_lb, c_ub] = fj_cpu.cached_cstr_bounds[i].get(); auto cstr_idx = fj_cpu.h_reverse_constraints[i]; - fj_cpu.unique_cstrs_accessed_window.insert(cstr_idx); auto cstr_coeff = fj_cpu.h_reverse_coefficients[i]; f_t old_lhs = fj_cpu.h_lhs[cstr_idx]; @@ -2072,6 +2070,182 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w #endif } +template +static std::vector copy_to_host_async(const rmm::device_uvector& input, + rmm::cuda_stream_view stream) +{ + std::vector output(input.size()); + raft::copy(output.data(), input.data(), input.size(), stream); + return output; +} + +template +std::unique_ptr> init_fj_cpu_from_optimization_problem( + const optimization_problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption_flag, + fj_settings_t settings) +{ + using f_t2 = typename type_2::type; + + raft::common::nvtx::range scope("init_fj_cpu_from_optimization_problem"); + + const i_t n_variables = problem.get_n_variables(); + const i_t n_constraints = problem.get_n_constraints(); + const i_t nnz = problem.get_nnz(); + auto stream = problem.get_handle_ptr()->get_stream(); + + auto coefficients = copy_to_host_async(problem.get_constraint_matrix_values(), stream); + auto variables = copy_to_host_async(problem.get_constraint_matrix_indices(), stream); + auto offsets = copy_to_host_async(problem.get_constraint_matrix_offsets(), stream); + auto objective_coefficients = copy_to_host_async(problem.get_objective_coefficients(), stream); + auto variable_lower_bounds = copy_to_host_async(problem.get_variable_lower_bounds(), stream); + auto variable_upper_bounds = copy_to_host_async(problem.get_variable_upper_bounds(), stream); + auto constraint_lower_bounds = copy_to_host_async(problem.get_constraint_lower_bounds(), stream); + auto constraint_upper_bounds = copy_to_host_async(problem.get_constraint_upper_bounds(), stream); + auto constraint_bounds = copy_to_host_async(problem.get_constraint_bounds(), stream); + auto row_types = copy_to_host_async(problem.get_row_types(), stream); + auto variable_types = copy_to_host_async(problem.get_variable_types(), stream); + problem.get_handle_ptr()->sync_stream(); + + cuopt_assert(coefficients.size() == (size_t)nnz, "coefficient size mismatch"); + cuopt_assert(variables.size() == (size_t)nnz, "variable index size mismatch"); + cuopt_assert(offsets.size() == (size_t)(n_constraints + 1), + "constraint offset size mismatch"); + cuopt_assert(!offsets.empty() && offsets.front() == 0, "invalid first constraint offset"); + cuopt_assert(offsets.back() == nnz, "invalid final constraint offset"); + cuopt_assert(std::is_sorted(offsets.begin(), offsets.end()), "unsorted constraint offsets"); + cuopt_assert( + std::all_of(variables.begin(), + variables.end(), + [n_variables](i_t variable) { return variable >= 0 && variable < n_variables; }), + "variable index out of range"); + cuopt_assert(objective_coefficients.size() == (size_t)n_variables, + "objective size mismatch"); + cuopt_assert(variable_lower_bounds.empty() || + variable_lower_bounds.size() == (size_t)n_variables, + "variable lower bound size mismatch"); + cuopt_assert(variable_upper_bounds.empty() || + variable_upper_bounds.size() == (size_t)n_variables, + "variable upper bound size mismatch"); + + if (constraint_lower_bounds.empty() && constraint_upper_bounds.empty()) { + cuopt_assert(row_types.size() == (size_t)n_constraints, "row type size mismatch"); + cuopt_assert(constraint_bounds.size() == (size_t)n_constraints, + "constraint bound size mismatch"); + constraint_lower_bounds.resize(n_constraints); + constraint_upper_bounds.resize(n_constraints); + for (i_t row = 0; row < n_constraints; ++row) { + const f_t bound = constraint_bounds[row]; + if (row_types[row] == 'E') { + constraint_lower_bounds[row] = bound; + constraint_upper_bounds[row] = bound; + } else if (row_types[row] == 'G') { + constraint_lower_bounds[row] = bound; + constraint_upper_bounds[row] = std::numeric_limits::infinity(); + } else { + cuopt_assert(row_types[row] == 'L', "invalid row type"); + constraint_lower_bounds[row] = -std::numeric_limits::infinity(); + constraint_upper_bounds[row] = bound; + } + } + } else { + cuopt_assert(constraint_lower_bounds.size() == (size_t)n_constraints, + "constraint lower bound size mismatch"); + cuopt_assert(constraint_upper_bounds.size() == (size_t)n_constraints, + "constraint upper bound size mismatch"); + } + + if (variable_lower_bounds.empty()) { variable_lower_bounds.assign(n_variables, f_t{0}); } + if (variable_upper_bounds.empty()) { + variable_upper_bounds.assign(n_variables, std::numeric_limits::infinity()); + } + if (variable_types.empty()) { variable_types.assign(n_variables, var_t::CONTINUOUS); } + cuopt_assert(variable_types.size() == (size_t)n_variables, + "variable type size mismatch"); + + if (problem.get_sense()) { + std::transform(objective_coefficients.begin(), + objective_coefficients.end(), + objective_coefficients.begin(), + std::negate{}); + } + + std::vector variable_bounds(n_variables); + std::vector is_binary_variable(n_variables, 0); + std::vector binary_indices; + binary_indices.reserve(n_variables); + i_t n_integer_vars = 0; + for (i_t variable = 0; variable < n_variables; ++variable) { + f_t lower = variable_lower_bounds[variable]; + f_t upper = variable_upper_bounds[variable]; + const bool is_integer = variable_types[variable] == var_t::INTEGER; + if (is_integer) { + lower = std::ceil(lower); + upper = std::floor(upper); + ++n_integer_vars; + } + cuopt_assert(lower <= upper, "crossing variable bounds"); + variable_bounds[variable] = f_t2{lower, upper}; + if (is_integer && lower == f_t{0} && upper == f_t{1}) { + is_binary_variable[variable] = 1; + binary_indices.push_back(variable); + } + } + + csr_matrix_t csr(n_constraints, n_variables, nnz); + csr.x = coefficients; + csr.j = variables; + csr.row_start = offsets; + csc_matrix_t csc(n_constraints, n_variables, nnz); + csr.to_compressed_col(csc); + + std::vector assignment(n_variables, f_t{0}); + for (i_t variable = 0; variable < n_variables; ++variable) { + f_t value = std::clamp( + f_t{0}, get_lower(variable_bounds[variable]), get_upper(variable_bounds[variable])); + if (variable_types[variable] == var_t::INTEGER) { value = std::round(value); } + assignment[variable] = value; + } + + auto fj_cpu = std::make_unique>(preemption_flag); + fj_cpu->view = typename fj_t::climber_data_t::view_t{}; + fj_cpu->pb_ptr = nullptr; + fj_cpu->settings = settings; + + fj_cpu->h_reverse_coefficients = std::move(csc.x); + fj_cpu->h_reverse_constraints = std::move(csc.i); + fj_cpu->h_reverse_offsets = std::move(csc.col_start); + fj_cpu->h_coefficients = std::move(coefficients); + fj_cpu->h_offsets = std::move(offsets); + fj_cpu->h_variables = std::move(variables); + fj_cpu->h_obj_coeffs = std::move(objective_coefficients); + fj_cpu->h_var_bounds = std::move(variable_bounds); + fj_cpu->h_cstr_lb = std::move(constraint_lower_bounds); + fj_cpu->h_cstr_ub = std::move(constraint_upper_bounds); + fj_cpu->h_var_types = std::move(variable_types); + fj_cpu->h_is_binary_variable = std::move(is_binary_variable); + fj_cpu->h_binary_indices = std::move(binary_indices); + fj_cpu->h_cstr_left_weights.resize(n_constraints, f_t{1}); + fj_cpu->h_cstr_right_weights.resize(n_constraints, f_t{1}); + fj_cpu->max_weight = f_t{1}; + fj_cpu->h_objective_weight = f_t{0}; + fj_cpu->h_assignment = assignment; + fj_cpu->h_best_assignment = std::move(assignment); + fj_cpu->h_lhs.resize(n_constraints); + fj_cpu->h_lhs_sumcomp.resize(n_constraints, f_t{0}); + fj_cpu->h_tabu_nodec_until.resize(n_variables, 0); + fj_cpu->h_tabu_noinc_until.resize(n_variables, 0); + fj_cpu->h_tabu_lastdec.resize(n_variables, 0); + fj_cpu->h_tabu_lastinc.resize(n_variables, 0); + fj_cpu->iterations = 0; + fj_cpu->settings.seed = cuopt::seed_generator::get_seed(); + + finalize_fj_cpu_host_initialization( + *fj_cpu, n_variables, n_constraints, n_integer_vars, nnz, tolerances); + return fj_cpu; +} + template std::unique_ptr> init_fj_cpu_standalone( problem_t& problem, @@ -2170,6 +2344,11 @@ template std::unique_ptr> init_fj_cpu_standalone( solution_t& solution, std::atomic& preemption_flag, fj_settings_t settings); +template std::unique_ptr> init_fj_cpu_from_optimization_problem( + const optimization_problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption_flag, + fj_settings_t settings); template void finalize_fj_cpu_host_initialization( fj_cpu_climber_t& fj_cpu, int n_variables, @@ -2190,6 +2369,11 @@ template std::unique_ptr> init_fj_cpu_standalone( solution_t& solution, std::atomic& preemption_flag, fj_settings_t settings); +template std::unique_ptr> init_fj_cpu_from_optimization_problem( + const optimization_problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption_flag, + fj_settings_t settings); template void finalize_fj_cpu_host_initialization( fj_cpu_climber_t& fj_cpu, int n_variables, diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index bb528f54ea..89db669dd0 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -232,4 +232,11 @@ std::unique_ptr> init_fj_cpu_standalone( std::atomic& preemption_flag, fj_settings_t settings = fj_settings_t{}); +template +std::unique_ptr> init_fj_cpu_from_optimization_problem( + const optimization_problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption_flag, + fj_settings_t settings = fj_settings_t{}); + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/semi_continuous.cu b/cpp/src/mip_heuristics/presolve/semi_continuous.cu index 33b7efff0e..51d2552746 100644 --- a/cpp/src/mip_heuristics/presolve/semi_continuous.cu +++ b/cpp/src/mip_heuristics/presolve/semi_continuous.cu @@ -115,6 +115,8 @@ bool reformulate_semi_continuous(optimization_problem_t& op_problem, std::vector* used_fallback_big_m, std::vector* semi_continuous_binary_to_original_indices) { + if (!op_problem.has_semi_continuous_variables()) { return false; } + // 1. Identify semi-continuous variables auto var_types = op_problem.get_variable_types_host(); auto var_lb = op_problem.get_variable_lower_bounds_host(); diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index 162a5ba291..0fc4230192 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -28,6 +28,7 @@ #include #include #include +#include #include #include @@ -254,7 +255,6 @@ mip_solution_t run_mip_solver( 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; @@ -269,22 +269,19 @@ mip_solution_t run_mip_solver( mip_solver_settings_accessor::get_semi_continuous_original_num_variables( settings), ctx_ptr = &solver.context, - early_fj_start](f_t solver_obj, - f_t user_obj, - const std::vector& assignment, - const char* heuristic_name) { + &timer](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", + "New solution from early primal heuristics (%s). Objective %+.6e. Time %.3f", heuristic_name, user_obj, - elapsed); + timer.elapsed_time()); invoke_solution_callbacks(mip_callbacks, has_semi_continuous_callback_translation, semi_continuous_original_num_variables, @@ -376,16 +373,6 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p raft::common::nvtx::range fun_scope("Running solver"); auto timer = timer_t(time_limit); - problem_checking_t::check_problem_representation(op_problem); - problem_checking_t::check_initial_solution_representation(op_problem, settings); - - CUOPT_LOG_INFO( - "Solving a problem with %d constraints, %d variables (%d integers), and %d nonzeros", - op_problem.get_n_constraints(), - op_problem.get_n_variables(), - op_problem.get_n_integers(), - op_problem.get_nnz()); - // Reformulate semi-continuous variables (x = 0 OR L <= x <= U) before Papilo presolve. // Uses deterministic CPU bounds strengthening to derive tight upper bounds for SC vars with // infinite UB. @@ -407,15 +394,6 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p settings, n_orig_before_sc, semi_continuous_binary_to_original_indices); } - op_problem.print_scaling_information(); - - // Check for crossing bounds. Return infeasible if there are any - if (problem_checking_t::has_crossing_bounds(op_problem)) { - return mip_solution_t(mip_termination_status_t::Infeasible, - solver_stats_t{}, - op_problem.get_handle_ptr()->get_stream()); - } - for (auto callback : settings.get_mip_callbacks()) { auto callback_num_variables = op_problem.get_n_variables(); if (mip_solver_settings_accessor::has_semi_continuous_callback_translation( @@ -444,16 +422,6 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p } #endif - if (settings.mip_scaling != CUOPT_MIP_SCALING_OFF) { - mip::mip_scaling_strategy_t scaling(op_problem); - scaling.scale_problem(settings.mip_scaling != CUOPT_MIP_SCALING_NO_OBJECTIVE); - } - double presolve_time = 0.0; - std::unique_ptr> presolver; - std::optional> presolve_result_opt; - mip::problem_t problem( - op_problem, settings.get_tolerances(), settings.determinism_mode == CUOPT_MODE_DETERMINISTIC); - auto run_presolve = settings.presolver != presolver_t::None; bool has_set_solution_callback = false; for (auto callback : settings.get_mip_callbacks()) { @@ -481,8 +449,8 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p std::vector early_incumbent_pool; // Track best incumbent found during presolve (shared across CPU and GPU FJ). - // early_best_objective is in the original problem's solver-space (always minimization), - // used for fast comparison in the callback. + // The CPU and GPU heuristics can use differently scaled solver spaces, so compare their + // objectives in a common minimization-oriented user space. // early_best_user_obj is the corresponding user-space objective, // passed to run_mip for correct cross-space conversion. // We attempt to crush early-heuristics solutions into the presolved space. @@ -491,7 +459,7 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p // but is dropped due to these dual reductions, and we lose a good solution. // This is why we still keep the solution around in original-space // and later extract it at the end of the solve. - std::atomic early_best_objective{std::numeric_limits::infinity()}; + std::atomic early_best_user_score{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; @@ -500,57 +468,92 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p std::unique_ptr> early_gpufj; 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_incumbent_pool, - &early_callback_mutex, - early_fj_start, - mip_callbacks = settings.get_mip_callbacks(), - has_semi_continuous_callback_translation = - mip_solver_settings_accessor::has_semi_continuous_callback_translation( - settings), - semi_continuous_original_num_variables = - mip_solver_settings_accessor::get_semi_continuous_original_num_variables( - settings), - 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_assignment = assignment; - early_incumbent_pool.push_back({user_obj, 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, - has_semi_continuous_callback_translation, - semi_continuous_original_num_variables, - user_obj, - user_assignment, - no_bound); - }; + op_problem.get_problem_category() != problem_category_t::LP && + op_problem.get_n_constraints() > 0; + const f_t objective_sense = + op_problem.get_objective_scaling_factor() >= f_t{0} ? f_t{1} : f_t{-1}; + f_t no_bound = objective_sense > f_t{0} ? (f_t)-1e20 : (f_t)1e20; + auto early_fj_callback = + [&early_best_user_score, + &early_best_user_obj, + &early_best_user_assignment, + &early_incumbent_pool, + &early_callback_mutex, + &timer, + objective_sense, + mip_callbacks = settings.get_mip_callbacks(), + has_semi_continuous_callback_translation = + mip_solver_settings_accessor::has_semi_continuous_callback_translation(settings), + semi_continuous_original_num_variables = + mip_solver_settings_accessor::get_semi_continuous_original_num_variables( + settings), + no_bound]( + f_t, f_t user_obj, const std::vector& assignment, const char* heuristic_name) { + std::lock_guard lock(early_callback_mutex); + const f_t objective = objective_sense * user_obj; + if (objective >= early_best_user_score.load()) { return; } + early_best_user_score.store(objective); + early_best_user_obj = user_obj; + early_best_user_assignment = assignment; + early_incumbent_pool.push_back({user_obj, assignment}); + CUOPT_LOG_INFO("New solution from early primal heuristics (%s). Objective %+.6e. Time %.3f", + heuristic_name, + user_obj, + timer.elapsed_time()); + auto user_assignment = assignment; + invoke_solution_callbacks(mip_callbacks, + has_semi_continuous_callback_translation, + semi_continuous_original_num_variables, + user_obj, + user_assignment, + no_bound); + }; + if (run_early_fj) { // Start early CPUFJ on original problem (will restart on presolved problem after Papilo) early_cpufj = std::make_unique>( op_problem, settings.get_tolerances(), early_fj_callback); early_cpufj->start(); CUOPT_LOG_DEBUG("Started early CPUFJ on original problem"); + } + + auto early_cpufj_guard = cuopt::scope_guard([&]() { + if (early_cpufj) { + early_cpufj->stop(); + early_cpufj.reset(); + } + }); + + problem_checking_t::check_problem_representation(op_problem); + problem_checking_t::check_initial_solution_representation(op_problem, settings); + + CUOPT_LOG_INFO( + "Solving a problem with %d constraints, %d variables (%d integers), and %d nonzeros", + op_problem.get_n_constraints(), + op_problem.get_n_variables(), + op_problem.get_n_integers(), + op_problem.get_nnz()); + op_problem.print_scaling_information(); + + // Check for crossing bounds. Return infeasible if there are any + if (problem_checking_t::has_crossing_bounds(op_problem)) { + return mip_solution_t(mip_termination_status_t::Infeasible, + solver_stats_t{}, + op_problem.get_handle_ptr()->get_stream()); + } + + if (settings.mip_scaling != CUOPT_MIP_SCALING_OFF) { + mip::mip_scaling_strategy_t scaling(op_problem); + scaling.scale_problem(settings.mip_scaling != CUOPT_MIP_SCALING_NO_OBJECTIVE); + } + double presolve_time = 0.0; + std::unique_ptr> presolver; + std::optional> presolve_result_opt; + mip::problem_t problem( + op_problem, settings.get_tolerances(), settings.determinism_mode == CUOPT_MODE_DETERMINISTIC); + + if (run_early_fj) { // Start early GPU FJ (uses GPU while CPU is busy with Papilo) early_gpufj = std::make_unique>(op_problem, settings, early_fj_callback); diff --git a/cpp/src/pdlp/cpu_optimization_problem.cpp b/cpp/src/pdlp/cpu_optimization_problem.cpp index 4b970eb6ec..93e86b7da6 100644 --- a/cpp/src/pdlp/cpu_optimization_problem.cpp +++ b/cpp/src/pdlp/cpu_optimization_problem.cpp @@ -29,20 +29,36 @@ namespace cuopt::mathematical_optimization { namespace { -// Classify a problem as LP / MIP / IP from its (enum) variable types. Single source of truth -// shared by set_variable_types() and adopt_from_mps_data_model() so the detection rule lives in -// one place. Empty types (no variables declared) classify as LP, matching the populate path where -// set_variable_types() is skipped and the category keeps its LP default. -problem_category_t problem_category_from_variable_types(const std::vector& variable_types) -{ - if (variable_types.empty()) { return problem_category_t::LP; } - const std::size_t n_discrete = static_cast( - std::count_if(variable_types.begin(), variable_types.end(), [](var_t v) { - return v == var_t::INTEGER || v == var_t::SEMI_CONTINUOUS; - })); - if (n_discrete == variable_types.size()) { return problem_category_t::IP; } - if (n_discrete > 0) { return problem_category_t::MIP; } - return problem_category_t::LP; +// Classify a problem as LP / MIP / IP from its (enum) variable types, and whether any +// SEMI_CONTINUOUS vars are present. Single source of truth shared by set_variable_types() and +// adopt_from_mps_data_model() so the detection rule lives in one place. Empty types (no variables +// declared) classify as LP with no SC, matching the populate path where set_variable_types() is +// skipped and the category keeps its LP default. +struct variable_type_summary_t { + problem_category_t category; + bool has_semi_continuous; +}; + +variable_type_summary_t summarize_variable_types(const std::vector& variable_types) +{ + if (variable_types.empty()) { + return {problem_category_t::LP, false}; + } + size_t n_discrete = 0; + bool has_semi_continuous = false; + for (var_t v : variable_types) { + if (v == var_t::SEMI_CONTINUOUS) { + has_semi_continuous = true; + ++n_discrete; + } else if (v == var_t::INTEGER) { + ++n_discrete; + } + } + if (n_discrete == variable_types.size()) { + return {problem_category_t::IP, has_semi_continuous}; + } + if (n_discrete > 0) { return {problem_category_t::MIP, has_semi_continuous}; } + return {problem_category_t::LP, false}; } } // namespace @@ -232,7 +248,9 @@ void cpu_optimization_problem_t::set_variable_types(const var_t* varia variable_types_.resize(size); std::copy(variable_types, variable_types + size, variable_types_.begin()); - problem_category_ = problem_category_from_variable_types(variable_types_); + const auto summary = summarize_variable_types(variable_types_); + problem_category_ = summary.category; + has_semi_continuous_variables_ = summary.has_semi_continuous; } template @@ -513,6 +531,12 @@ problem_category_t cpu_optimization_problem_t::get_problem_category() return problem_category_; } +template +bool cpu_optimization_problem_t::has_semi_continuous_variables() const noexcept +{ + return has_semi_continuous_variables_; +} + template const std::vector& cpu_optimization_problem_t::get_variable_names() const { @@ -1171,7 +1195,9 @@ void cpu_optimization_problem_t::adopt_from_mps_data_model( for (size_t i = 0; i < model.var_types_.size(); ++i) { variable_types_[i] = char_to_var_type(model.var_types_[i]); } - problem_category_ = problem_category_from_variable_types(variable_types_); + const auto summary = summarize_variable_types(variable_types_); + problem_category_ = summary.category; + has_semi_continuous_variables_ = summary.has_semi_continuous; if (model.has_quadratic_constraints()) { move_quadratic_constraints_from_model(*this, model.quadratic_constraints_); diff --git a/cpp/src/pdlp/optimization_problem.cu b/cpp/src/pdlp/optimization_problem.cu index 95457e2556..18222ed012 100644 --- a/cpp/src/pdlp/optimization_problem.cu +++ b/cpp/src/pdlp/optimization_problem.cu @@ -54,6 +54,8 @@ namespace cuopt::mathematical_optimization { +constexpr size_t host_variable_type_summary_limit = 50'000; + template optimization_problem_t::optimization_problem_t(raft::handle_t const* handle_ptr) : handle_ptr_(handle_ptr), @@ -101,6 +103,7 @@ optimization_problem_t::optimization_problem_t( objective_name_{other.get_objective_name()}, problem_name_{other.get_problem_name()}, problem_category_{other.get_problem_category()}, + has_semi_continuous_variables_{other.has_semi_continuous_variables()}, var_names_{other.get_variable_names()}, row_names_{other.get_row_names()}, quadratic_constraints_{other.get_quadratic_constraints()} @@ -285,14 +288,40 @@ void optimization_problem_t::set_variable_types(const var_t* variable_ variable_types_.resize(size, stream_view_); raft::copy(variable_types_.data(), variable_types, size, stream_view_); - // Auto-detect problem category based on variable types. + // Auto-detect problem category and cache presence of SEMI_CONTINUOUS vars. // SEMI_CONTINUOUS vars will be reformulated into binary + continuous before solving, // so a problem with only SC vars is treated as MIP. - i_t n_discrete = thrust::count_if( - handle_ptr_->get_thrust_policy(), - variable_types_.begin(), - variable_types_.end(), - [] __device__(auto val) { return val == var_t::INTEGER || val == var_t::SEMI_CONTINUOUS; }); + // Prefer host-side for small instances to reduce latency between launch and first-feasible. + i_t n_discrete = 0; + bool has_semi_continuous_variables = false; + if ((size_t)size < host_variable_type_summary_limit) { + for (i_t i = 0; i < size; ++i) { + const var_t val = variable_types[i]; + if (val == var_t::SEMI_CONTINUOUS) { + has_semi_continuous_variables = true; + ++n_discrete; + } else if (val == var_t::INTEGER) { + ++n_discrete; + } + } + } else { + auto is_discrete = [] __host__ __device__(var_t val) { + return val == var_t::INTEGER || val == var_t::SEMI_CONTINUOUS; + }; + auto is_semi_continuous = [] __host__ __device__(var_t val) { + return val == var_t::SEMI_CONTINUOUS; + }; + n_discrete = thrust::count_if(handle_ptr_->get_thrust_policy(), + variable_types_.begin(), + variable_types_.end(), + is_discrete); + has_semi_continuous_variables = + thrust::count_if(handle_ptr_->get_thrust_policy(), + variable_types_.begin(), + variable_types_.end(), + is_semi_continuous) > 0; + } + has_semi_continuous_variables_ = has_semi_continuous_variables; if (n_discrete == size) { problem_category_ = problem_category_t::IP; } else if (n_discrete > 0) { @@ -580,6 +609,12 @@ problem_category_t optimization_problem_t::get_problem_category() cons return problem_category_; } +template +bool optimization_problem_t::has_semi_continuous_variables() const noexcept +{ + return has_semi_continuous_variables_; +} + template const std::vector& optimization_problem_t::get_variable_names() const { diff --git a/cpp/src/utilities/version_info.cpp b/cpp/src/utilities/version_info.cpp index 67ec9e6794..3fe1074f87 100644 --- a/cpp/src/utilities/version_info.cpp +++ b/cpp/src/utilities/version_info.cpp @@ -15,135 +15,208 @@ #include #include -#include -#include -#include -#include -#include -#include +#include +#include +#include + +#include +#include +#include +#include namespace cuopt { -static int get_physical_cores() +// Reads up to buf_size-1 bytes, NUL-terminates, strips trailing whitespace/NULs. +// Returns bytes kept (excluding the terminator), or -1 on failure. +static ssize_t read_file_buf(const char* path, char* buf, size_t buf_size) +{ + if (buf_size == 0) return -1; + const int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + const ssize_t n = read(fd, buf, buf_size - 1); + close(fd); + if (n < 0) return -1; + buf[n] = '\0'; + + // Device-tree properties are often NUL-terminated without a trailing newline. + size_t len = 0; + while (len < (size_t)n && buf[len] != '\0') { + ++len; + } + buf[len] = '\0'; + while (len > 0 && + (buf[len - 1] == '\n' || buf[len - 1] == '\r' || buf[len - 1] == ' ' || + buf[len - 1] == '\t')) { + buf[--len] = '\0'; + } + return (ssize_t)len; +} + +// Parses a kernel CPU list ("0-3,8,10-11") into cpus[0..max_cpus). Returns count written. +static int parse_cpu_list(const char* list, int* cpus, int max_cpus) { - std::ifstream cpuinfo("/proc/cpuinfo"); - if (!cpuinfo.is_open()) return 0; - - std::string line; - int physical_id = -1, core_id = -1; - std::set> cores; - - while (std::getline(cpuinfo, line)) { - if (line.find("physical id") != std::string::npos) { - physical_id = std::stoi(line.substr(line.find(":") + 1)); - } else if (line.find("core id") != std::string::npos) { - core_id = std::stoi(line.substr(line.find(":") + 1)); + int count = 0; + const char* p = list; + while (*p && count < max_cpus) { + while (*p == ',' || *p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') { + ++p; } + if (*p == '\0') break; + + char* end = nullptr; + const long lo = std::strtol(p, &end, 10); + if (end == p) break; + p = end; - if (physical_id != -1 && core_id != -1) { - cores.insert({physical_id, core_id}); - physical_id = -1; - core_id = -1; + if (*p == '-') { + ++p; + const long hi = std::strtol(p, &end, 10); + if (end == p) break; + p = end; + for (long cpu = lo; cpu <= hi && count < max_cpus; ++cpu) { + cpus[count++] = (int)cpu; + } + } else { + cpus[count++] = (int)lo; } } + return count; +} - if (cores.empty()) { - cpuinfo.clear(); - cpuinfo.seekg(0); - while (std::getline(cpuinfo, line)) { - if (line.find("cpu cores") != std::string::npos) { - return std::stoi(line.substr(line.find(":") + 1)); +static void mark_cpus_from_list(const char* list, char visited[CPU_SETSIZE]) +{ + const char* p = list; + while (*p) { + while (*p == ',' || *p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') { + ++p; + } + if (*p == '\0') break; + + char* end = nullptr; + const long lo = std::strtol(p, &end, 10); + if (end == p) break; + p = end; + + if (*p == '-') { + ++p; + const long hi = std::strtol(p, &end, 10); + if (end == p) break; + p = end; + for (long cpu = lo; cpu <= hi; ++cpu) { + if (cpu >= 0 && cpu < CPU_SETSIZE) { visited[cpu] = 1; } } + } else if (lo >= 0 && lo < CPU_SETSIZE) { + visited[lo] = 1; } - return 1; } - return cores.size(); } -static std::string get_cpu_model_from_proc() +// CPUs this process may run on (respects Slurm/cgroup cpusets, taskset, etc.). +static int get_allowed_cpus(int* cpus, int max_cpus) { - std::ifstream cpuinfo("/proc/cpuinfo"); - if (!cpuinfo.is_open()) return ""; - - std::string line; - while (std::getline(cpuinfo, line)) { - std::size_t pos = line.find("model name"); - if (pos == std::string::npos) pos = line.find("Processor"); - if (pos != std::string::npos) { - std::size_t colon = line.find(':', pos); - if (colon != std::string::npos) return line.substr(colon + 2); // Skip ": " + cpu_set_t set; + CPU_ZERO(&set); + int count = 0; + if (sched_getaffinity(0, sizeof(set), &set) == 0) { + for (int cpu = 0; cpu < CPU_SETSIZE && count < max_cpus; ++cpu) { + if (CPU_ISSET(cpu, &set)) { cpus[count++] = cpu; } } } - return ""; + if (count > 0) return count; + + char buf[256]; + if (read_file_buf("/sys/devices/system/cpu/online", buf, sizeof(buf)) < 0) return 0; + return parse_cpu_list(buf, cpus, max_cpus); } -// From https://gcc.gnu.org/onlinedocs/gcc/x86-Built-in-Functions.html -// Also supported by clang -static std::string get_cpu_model_builtin() +static int get_physical_cores(const int* allowed_cpus, int allowed_count) { -#if (defined(__x86_64__) || defined(__i386__)) && (defined(__GNUC__) || defined(__clang__)) - __builtin_cpu_init(); - return __builtin_cpu_is("amd") ? "AMD CPU" - : __builtin_cpu_is("intel") ? "Intel CPU" - : __builtin_cpu_is("atom") ? "Intel Atom CPU" - : __builtin_cpu_is("slm") ? "Intel Silvermont CPU" - : __builtin_cpu_is("core2") ? "Intel Core 2 CPU" - : __builtin_cpu_is("corei7") ? "Intel Core i7 CPU" - : __builtin_cpu_is("nehalem") ? "Intel Core i7 Nehalem CPU" - : __builtin_cpu_is("westmere") ? "Intel Core i7 Westmere CPU" - : __builtin_cpu_is("sandybridge") ? "Intel Core i7 Sandy Bridge CPU" - : __builtin_cpu_is("ivybridge") ? "Intel Core i7 Ivy Bridge CPU" - : __builtin_cpu_is("haswell") ? "Intel Core i7 Haswell CPU" - : __builtin_cpu_is("broadwell") ? "Intel Core i7 Broadwell CPU" - : __builtin_cpu_is("skylake") ? "Intel Core i7 Skylake CPU" - : __builtin_cpu_is("skylake-avx512") ? "Intel Core i7 Skylake AVX512 CPU" - : __builtin_cpu_is("cannonlake") ? "Intel Core i7 Cannon Lake CPU" - : __builtin_cpu_is("icelake-client") ? "Intel Core i7 Ice Lake Client CPU" - : __builtin_cpu_is("icelake-server") ? "Intel Core i7 Ice Lake Server CPU" - : __builtin_cpu_is("cascadelake") ? "Intel Core i7 Cascadelake CPU" - : __builtin_cpu_is("tigerlake") ? "Intel Core i7 Tigerlake CPU" - : __builtin_cpu_is("cooperlake") ? "Intel Core i7 Cooperlake CPU" - : __builtin_cpu_is("sapphirerapids") ? "Intel Core i7 sapphirerapids CPU" - : __builtin_cpu_is("alderlake") ? "Intel Core i7 Alderlake CPU" - : __builtin_cpu_is("rocketlake") ? "Intel Core i7 Rocketlake CPU" - : __builtin_cpu_is("graniterapids") ? "Intel Core i7 graniterapids CPU" - : __builtin_cpu_is("graniterapids-d") ? "Intel Core i7 graniterapids D CPU" - : __builtin_cpu_is("bonnell") ? "Intel Atom Bonnell CPU" - : __builtin_cpu_is("silvermont") ? "Intel Atom Silvermont CPU" - : __builtin_cpu_is("goldmont") ? "Intel Atom Goldmont CPU" - : __builtin_cpu_is("goldmont-plus") ? "Intel Atom Goldmont Plus CPU" - : __builtin_cpu_is("tremont") ? "Intel Atom Tremont CPU" - : __builtin_cpu_is("sierraforest") ? "Intel Atom Sierra Forest CPU" - : __builtin_cpu_is("grandridge") ? "Intel Atom Grand Ridge CPU" - : __builtin_cpu_is("amdfam10h") ? "AMD Family 10h CPU" - : __builtin_cpu_is("barcelona") ? "AMD Family 10h Barcelona CPU" - : __builtin_cpu_is("shanghai") ? "AMD Family 10h Shanghai CPU" - : __builtin_cpu_is("istanbul") ? "AMD Family 10h Istanbul CPU" - : __builtin_cpu_is("btver1") ? "AMD Family 14h CPU" - : __builtin_cpu_is("amdfam15h") ? "AMD Family 15h CPU" - : __builtin_cpu_is("bdver1") ? "AMD Family 15h Bulldozer version 1" - : __builtin_cpu_is("bdver2") ? "AMD Family 15h Bulldozer version 2" - : __builtin_cpu_is("bdver3") ? "AMD Family 15h Bulldozer version 3" - : __builtin_cpu_is("bdver4") ? "AMD Family 15h Bulldozer version 4" - : __builtin_cpu_is("btver2") ? "AMD Family 16h CPU" - : __builtin_cpu_is("amdfam17h") ? "AMD Family 17h CPU" - : __builtin_cpu_is("znver1") ? "AMD Family 17h Zen version 1" - : __builtin_cpu_is("znver2") ? "AMD Family 17h Zen version 2" - : __builtin_cpu_is("amdfam19h") ? "AMD Family 19h CPU" - : "Unknown"; -#else - return "Unknown"; -#endif + if (allowed_count <= 0) return 0; + + char visited[CPU_SETSIZE]; + std::memset(visited, 0, sizeof(visited)); + int cores = 0; + + for (int i = 0; i < allowed_count; ++i) { + const int cpu = allowed_cpus[i]; + if (cpu < 0 || cpu >= CPU_SETSIZE || visited[cpu]) continue; + + char path[128]; + char buf[256]; + snprintf(path, + sizeof(path), + "/sys/devices/system/cpu/cpu%d/topology/core_cpus_list", + cpu); + ssize_t n = read_file_buf(path, buf, sizeof(buf)); + if (n < 0) { + snprintf(path, + sizeof(path), + "/sys/devices/system/cpu/cpu%d/topology/thread_siblings_list", + cpu); + n = read_file_buf(path, buf, sizeof(buf)); + } + + if (n >= 0) { + mark_cpus_from_list(buf, visited); + } + visited[cpu] = 1; + ++cores; + } + + return cores > 0 ? cores : allowed_count; +} + +static bool copy_stripped(char* dst, size_t dst_size, const char* src) +{ + if (dst_size == 0) return false; + size_t len = std::strlen(src); + while (len > 0 && (src[len - 1] == '\n' || src[len - 1] == '\r' || src[len - 1] == ' ')) { + --len; + } + if (len >= dst_size) len = dst_size - 1; + std::memcpy(dst, src, len); + dst[len] = '\0'; + return len > 0; +} + +static bool get_cpu_model_from_proc(char* out, size_t out_size) +{ + FILE* cpuinfo = fopen("/proc/cpuinfo", "r"); + if (cpuinfo == nullptr) return false; + + char line[512]; + while (fgets(line, sizeof(line), cpuinfo) != nullptr) { + const char* field = std::strstr(line, "model name"); + if (field == nullptr) field = std::strstr(line, "Processor"); + if (field == nullptr) continue; + + const char* colon = std::strchr(field, ':'); + if (colon == nullptr) continue; + ++colon; + while (*colon == ' ' || *colon == '\t') { + ++colon; + } + const bool ok = copy_stripped(out, out_size, colon); + fclose(cpuinfo); + return ok; + } + fclose(cpuinfo); + return false; } -static std::string get_cpu_model() +static void get_cpu_model(char* out, size_t out_size) { - if (auto model_from_proc = get_cpu_model_from_proc(); !model_from_proc.empty()) { - return model_from_proc; - } else if (auto model_from_builtin = get_cpu_model_builtin(); !model_from_builtin.empty()) { - return model_from_builtin; + if (get_cpu_model_from_proc(out, out_size)) return; + + char buf[256]; + if (read_file_buf("/sys/firmware/devicetree/base/model", buf, sizeof(buf)) >= 0 || + read_file_buf("/proc/device-tree/model", buf, sizeof(buf)) >= 0) { + if (copy_stripped(out, out_size, buf)) return; } - return "Unknown"; + if (read_file_buf("/sys/devices/virtual/dmi/id/product_name", buf, sizeof(buf)) >= 0) { + if (copy_stripped(out, out_size, buf)) return; + } + std::snprintf(out, out_size, "Unknown"); } static const char* get_simd_target() @@ -166,26 +239,28 @@ struct host_memory_info_t { static host_memory_info_t get_host_memory_info() { - std::ifstream meminfo("/proc/meminfo"); - if (!meminfo.is_open()) return {}; + FILE* meminfo = fopen("/proc/meminfo", "r"); + if (meminfo == nullptr) return {}; - std::string line; + char line[256]; long total_kb = 0; long available_kb = 0; long free_kb = 0; - while (std::getline(meminfo, line)) { - std::istringstream fields(line); - std::string key; + int found = 0; + while (found < 3 && fgets(line, sizeof(line), meminfo) != nullptr) { long value_kb = 0; - fields >> key >> value_kb; - if (key == "MemTotal:") { + if (std::sscanf(line, "MemTotal: %ld", &value_kb) == 1) { total_kb = value_kb; - } else if (key == "MemAvailable:") { + ++found; + } else if (std::sscanf(line, "MemAvailable: %ld", &value_kb) == 1) { available_kb = value_kb; - } else if (key == "MemFree:") { + ++found; + } else if (std::sscanf(line, "MemFree: %ld", &value_kb) == 1) { free_kb = value_kb; + ++found; } } + fclose(meminfo); if (available_kb == 0) { available_kb = free_kb; } constexpr double kb_per_gib = 1024.0 * 1024.0; @@ -209,14 +284,18 @@ void print_version_info(int num_devices) CUOPT_GIT_COMMIT_HASH, CUOPT_CPU_ARCHITECTURE, CUOPT_CUDA_ARCHITECTURES); + const auto memory = get_host_memory_info(); - CUOPT_LOG_INFO( - "CPU: %s, threads (physical/logical): %d/%d, RAM (available/total): %.2f / %.2f GiB", - get_cpu_model().c_str(), - get_physical_cores(), - std::thread::hardware_concurrency(), - memory.available_gb, - memory.total_gb); + int allowed_cpus[CPU_SETSIZE]; + const int allowed_count = get_allowed_cpus(allowed_cpus, CPU_SETSIZE); + char cpu_model[256]; + get_cpu_model(cpu_model, sizeof(cpu_model)); + CUOPT_LOG_INFO("CPU: %s, threads: %dC/%dT, RAM usage: %.2f/%.2fGiB", + cpu_model, + get_physical_cores(allowed_cpus, allowed_count), + allowed_count, + std::max(0.0, memory.total_gb - memory.available_gb), + memory.total_gb); CUOPT_LOG_INFO("CPU SIMD target: %s", get_simd_target()); for (int device_id = 0; device_id < num_devices; ++device_id) { From 10ddf0ee2c0c5ef638ba0691aa76690746486410 Mon Sep 17 00:00:00 2001 From: yboucher Date: Wed, 19 Aug 2026 03:10:25 -0700 Subject: [PATCH 34/78] more logs --- cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu | 7 +++++-- cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu | 6 ++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 4cce1df459..4db353acd9 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1086,8 +1086,11 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, fj_cpu.h_incumbent_objective - fj_cpu.settings.parameters.breakthrough_move_epsilon; fj_cpu.h_best_assignment = fj_cpu.h_assignment; fj_cpu.iterations_since_best = 0; - CUOPT_LOG_TRACE( - "%sCPUFJ: new best objective: %g", fj_cpu.log_prefix.c_str(), fj_cpu.h_incumbent_objective); + // DEBUG, and reporting the stored best rather than the pre-epsilon incumbent, + // so it matches the binary path and the end-of-solve incumbent audit. + CUOPT_LOG_DEBUG("%sCPUFJ new incumbent: objective %.17g", + fj_cpu.log_prefix.c_str(), + fj_cpu.h_best_objective); if (fj_cpu.improvement_callback) { double current_work_units = fj_cpu.work_units_elapsed.load(std::memory_order_acquire); fj_cpu.improvement_callback( diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index fe41614cbb..bf0768ced4 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -853,6 +853,12 @@ struct fj_bin_engine_t { climber.h_incumbent_objective = (f_t)incumbent_objective; climber.h_best_objective = (f_t)best_objective; climber.feasible_found = true; + // Emitted once per improvement so the benchmark harness can reconstruct the + // incumbent trajectory exactly, rather than sampling it at log_interval. + CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] new incumbent: objective %.17g", + climber.log_prefix.c_str(), + coefficient_bits(), + best_objective); if (climber.improvement_callback) { const double work_units = climber.work_units_elapsed.load(std::memory_order_acquire); climber.improvement_callback((f_t)best_objective, h_best, work_units); From 69ac5a2f83e1de59f91fdcc93caac5677ebe5e1c Mon Sep 17 00:00:00 2001 From: yboucher Date: Wed, 19 Aug 2026 04:03:00 -0700 Subject: [PATCH 35/78] ai review --- .../feasibility_jump/fj_cpu_binary.cu | 47 +++++++++++++++++-- cpp/src/mip_heuristics/solve.cu | 3 +- cpp/src/pdlp/optimization_problem.cu | 4 +- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index bf0768ced4..6010385533 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -16,6 +16,11 @@ #include +#include +#include +#include +#include + #include #include #include @@ -264,10 +269,24 @@ static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) } } - const auto& offsets = c.h_offsets; - const auto& coeffs = c.h_coefficients; - const auto& cstr_lb = c.h_cstr_lb; - const auto& cstr_ub = c.h_cstr_ub; + const auto& offsets = c.h_offsets; + const auto& reverse_offsets = c.h_reverse_offsets; + const auto& reverse_constraints = c.h_reverse_constraints; + const auto& coeffs = c.h_coefficients; + const auto& cstr_lb = c.h_cstr_lb; + const auto& cstr_ub = c.h_cstr_ub; + + cuopt_assert( + thrust::all_of( + thrust::host, + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(n), + [&reverse_offsets, &reverse_constraints](int32_t v) { + const auto first = reverse_constraints.begin() + reverse_offsets[v]; + const auto last = reverse_constraints.begin() + reverse_offsets[v + 1]; + return thrust::adjacent_find(thrust::host, first, last) == last; + }), + "duplicate variable in CSR row"); double max_abs_coefficient = 0; std::vector row_values; @@ -315,11 +334,19 @@ static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) } double row_abs_sum = 0; + double row_lhs_min = 0; + double row_lhs_max = 0; for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { const double a = row_s * coeffs[k]; cuopt_assert(is_integer(a, tol), "row scaling left a fractional coefficient"); - const double abs_a = std::fabs(std::round(a)); + const double integral_a = std::round(a); + const double abs_a = std::fabs(integral_a); row_abs_sum += abs_a; + if (integral_a < 0) { + row_lhs_min += integral_a; + } else { + row_lhs_max += integral_a; + } if (abs_a > max_abs_coefficient) max_abs_coefficient = abs_a; } @@ -340,6 +367,16 @@ static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) out.bad_row = r; return out; } + const double integral_side = std::round(scaled_side); + const double min_slack = + s == 0 ? row_lhs_min - integral_side : integral_side - row_lhs_max; + const double max_slack = + s == 0 ? row_lhs_max - integral_side : integral_side - row_lhs_min; + if (!fj_bin_in_int32(min_slack) || !fj_bin_in_int32(max_slack)) { + out.reject = fj_binary_reject_t::lhs_headroom; + out.bad_row = r; + return out; + } } // Free rows are dropped: trivially satisfied, contributing nothing to the search. out.n_split_constraints += (int32_t)lb_fin + (int32_t)ub_fin; diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index 0fc4230192..5371a87026 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -470,8 +470,7 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p bool run_early_fj = run_presolve && settings.determinism_mode != CUOPT_MODE_DETERMINISTIC && op_problem.get_problem_category() != problem_category_t::LP && op_problem.get_n_constraints() > 0; - const f_t objective_sense = - op_problem.get_objective_scaling_factor() >= f_t{0} ? f_t{1} : f_t{-1}; + const f_t objective_sense = op_problem.get_sense() ? f_t{-1} : f_t{1}; f_t no_bound = objective_sense > f_t{0} ? (f_t)-1e20 : (f_t)1e20; auto early_fj_callback = [&early_best_user_score, diff --git a/cpp/src/pdlp/optimization_problem.cu b/cpp/src/pdlp/optimization_problem.cu index 18222ed012..d4f669a118 100644 --- a/cpp/src/pdlp/optimization_problem.cu +++ b/cpp/src/pdlp/optimization_problem.cu @@ -295,8 +295,8 @@ void optimization_problem_t::set_variable_types(const var_t* variable_ i_t n_discrete = 0; bool has_semi_continuous_variables = false; if ((size_t)size < host_variable_type_summary_limit) { - for (i_t i = 0; i < size; ++i) { - const var_t val = variable_types[i]; + const auto h_variable_types = cuopt::host_copy(variable_types_, stream_view_); + for (const var_t val : h_variable_types) { if (val == var_t::SEMI_CONTINUOUS) { has_semi_continuous_variables = true; ++n_discrete; From 64abba9dd6c96a803bf7d9867d285d6402bb8e9d Mon Sep 17 00:00:00 2001 From: yboucher Date: Wed, 19 Aug 2026 07:13:59 -0700 Subject: [PATCH 36/78] fix build --- cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 6010385533..03685a4e83 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -16,8 +16,8 @@ #include -#include #include +#include #include #include From 0c771a39a808aacfd018efef94e4e4c2c324eba9 Mon Sep 17 00:00:00 2001 From: yboucher Date: Wed, 19 Aug 2026 23:45:36 -0700 Subject: [PATCH 37/78] some optimizing --- .../linear_programming/cuopt/run_cpufj.cu | 10 +- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 240 +++++++++++++++--- .../feasibility_jump/fj_cpu.cuh | 71 +++++- 3 files changed, 285 insertions(+), 36 deletions(-) diff --git a/benchmarks/linear_programming/cuopt/run_cpufj.cu b/benchmarks/linear_programming/cuopt/run_cpufj.cu index e62a8ef224..d071cc0648 100644 --- a/benchmarks/linear_programming/cuopt/run_cpufj.cu +++ b/benchmarks/linear_programming/cuopt/run_cpufj.cu @@ -129,14 +129,20 @@ int main(int argc, char** argv) mip::clamp_within_var_bounds(solution.assignment, &problem, &handle); handle.sync_stream(); - // Built serially: each climber host-copies the problem off the same stream. + // Built serially: only the first climber host-copies the problem off the stream, the rest clone + // its host data. std::vector> preemption_flags(n_climbers); std::vector>> climbers(n_climbers); for (int k = 0; k < n_climbers; ++k) { preemption_flags[k].store(false); mip::fj_settings_t settings; settings.seed = (int)(base_seed + k); - climbers[k] = mip::init_fj_cpu_standalone(problem, solution, preemption_flags[k], settings); + if (k == 0) { + climbers[k] = mip::init_fj_cpu_standalone(problem, solution, preemption_flags[k], settings); + } else { + climbers[k] = mip::init_fj_cpu_standalone_from_template( + problem, *climbers[0], preemption_flags[k], settings); + } // Portfolio diversification, decorrelated from the value RNG. std::mt19937 rng(base_seed + 7919u * k); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 4db353acd9..9adce2a3f7 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -18,6 +18,7 @@ #include +#include #include #include @@ -64,6 +65,16 @@ void finalize_fj_cpu_host_initialization( i_t nnz, const typename mip_solver_settings_t::tolerances_t& tolerances); +template +static void finalize_fj_cpu_host_initialization_from_template( + fj_cpu_climber_t& fj_cpu, + const fj_cpu_climber_t& tmpl, + i_t n_variables, + i_t n_constraints, + i_t n_integer_vars, + i_t nnz, + const typename mip_solver_settings_t::tolerances_t& tolerances); + template thrust::tuple get_mtm_for_bound(const typename fj_t::climber_data_t::view_t& fj, i_t var_idx, @@ -918,7 +929,7 @@ static void smooth_weights(fj_cpu_climber_t& fj_cpu) CPUFJ_NVTX_RANGE("CPUFJ::smooth_weights"); for (i_t cstr_idx = 0; cstr_idx < fj_cpu.view.pb.n_constraints; cstr_idx++) { // consider only satisfied constraints - if (fj_cpu.violated_constraints.count(cstr_idx)) continue; + if (fj_cpu.violated_constraints.contains(cstr_idx)) continue; f_t weight_l = max((f_t)0, fj_cpu.h_cstr_left_weights[cstr_idx] - 1); f_t weight_r = max((f_t)0, fj_cpu.h_cstr_right_weights[cstr_idx] - 1); @@ -1047,13 +1058,13 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, if (fabs(fj_cpu.h_lhs_sumcomp[cstr_idx]) > BIGVAL_THRESHOLD) fj_cpu.trigger_early_lhs_recomputation = true; - if (new_cost < -cstr_tolerance && !fj_cpu.violated_constraints.count(cstr_idx)) { + if (new_cost < -cstr_tolerance && !fj_cpu.violated_constraints.contains(cstr_idx)) { fj_cpu.violated_constraints.insert(cstr_idx); - cuopt_assert(fj_cpu.satisfied_constraints.count(cstr_idx) == 1, ""); - fj_cpu.satisfied_constraints.erase(cstr_idx); - } else if (!(new_cost < -cstr_tolerance) && fj_cpu.violated_constraints.count(cstr_idx)) { - cuopt_assert(fj_cpu.satisfied_constraints.count(cstr_idx) == 0, ""); - fj_cpu.violated_constraints.erase(cstr_idx); + cuopt_assert(fj_cpu.satisfied_constraints.contains(cstr_idx), ""); + fj_cpu.satisfied_constraints.remove(cstr_idx); + } else if (!(new_cost < -cstr_tolerance) && fj_cpu.violated_constraints.contains(cstr_idx)) { + cuopt_assert(!fj_cpu.satisfied_constraints.contains(cstr_idx), ""); + fj_cpu.violated_constraints.remove(cstr_idx); fj_cpu.satisfied_constraints.insert(cstr_idx); } @@ -1282,6 +1293,27 @@ static thrust::tuple find_mtm_move( return thrust::make_tuple(best_move, best_score); } +template +static void sample_with_replacement(const host_contiguous_set_t& pool, + i_t sample_size, + uint64_t seed, + std::vector& out) +{ + cuopt_assert(sample_size > 0, "invalid sample size"); + out.clear(); + const i_t pool_size = pool.size(); + if (pool_size == 0) { return; } + if (pool_size <= sample_size) { + out.assign(pool.begin(), pool.end()); + return; + } + out.reserve(sample_size); + cuopt::pcgenerator_t rng(seed); + for (i_t i = 0; i < sample_size; ++i) { + out.push_back(pool.contents[rng.next_u32() % (uint32_t)pool_size]); + } +} + template static thrust::tuple find_mtm_move_viol( fj_cpu_climber_t& fj_cpu, i_t sample_size = 100, bool localmin = false) @@ -1290,12 +1322,10 @@ static thrust::tuple find_mtm_move_viol( CPUFJ_NVTX_RANGE("CPUFJ::find_mtm_move_viol"); std::vector sampled_cstrs; - sampled_cstrs.reserve(sample_size); - std::sample(fj_cpu.violated_constraints.begin(), - fj_cpu.violated_constraints.end(), - std::back_inserter(sampled_cstrs), - sample_size, - fj_cpu.rng); + sample_with_replacement(fj_cpu.violated_constraints, + sample_size, + fj_cpu.settings.seed + fj_cpu.iterations, + sampled_cstrs); return find_mtm_move(fj_cpu, sampled_cstrs, localmin); } @@ -1308,12 +1338,10 @@ static thrust::tuple find_mtm_move_sat( CPUFJ_NVTX_RANGE("CPUFJ::find_mtm_move_sat"); std::vector sampled_cstrs; - sampled_cstrs.reserve(sample_size); - std::sample(fj_cpu.satisfied_constraints.begin(), - fj_cpu.satisfied_constraints.end(), - std::back_inserter(sampled_cstrs), - sample_size, - fj_cpu.rng); + sample_with_replacement(fj_cpu.satisfied_constraints, + sample_size, + fj_cpu.settings.seed + fj_cpu.iterations, + sampled_cstrs); return find_mtm_move(fj_cpu, sampled_cstrs); } @@ -1569,6 +1597,60 @@ static void init_fj_cpu(fj_cpu_climber_t& fj_cpu, problem.tolerances); } +template +static void init_fj_cpu_from_template(fj_cpu_climber_t& fj_cpu, + const fj_cpu_climber_t& tmpl, + problem_t& problem, + const std::vector& left_weights, + const std::vector& right_weights, + f_t objective_weight) +{ + cuopt_assert(tmpl.h_offsets.size() == static_cast(problem.n_constraints + 1), + "template built on a different problem"); + cuopt_assert(tmpl.h_reverse_offsets.size() == static_cast(problem.n_variables + 1), + "template built on a different problem"); + cuopt_assert(tmpl.h_coefficients.size() == static_cast(problem.nnz), + "template built on a different problem"); + + fj_cpu.view = typename fj_t::climber_data_t::view_t{}; + fj_cpu.view.pb = problem.view(); + fj_cpu.pb_ptr = &problem; + + fj_cpu.h_reverse_coefficients = tmpl.h_reverse_coefficients; + fj_cpu.h_reverse_constraints = tmpl.h_reverse_constraints; + fj_cpu.h_reverse_offsets = tmpl.h_reverse_offsets; + fj_cpu.h_coefficients = tmpl.h_coefficients; + fj_cpu.h_offsets = tmpl.h_offsets; + fj_cpu.h_variables = tmpl.h_variables; + fj_cpu.h_obj_coeffs = tmpl.h_obj_coeffs; + fj_cpu.h_var_bounds = tmpl.h_var_bounds; + fj_cpu.h_cstr_lb = tmpl.h_cstr_lb; + fj_cpu.h_cstr_ub = tmpl.h_cstr_ub; + fj_cpu.h_var_types = tmpl.h_var_types; + fj_cpu.h_is_binary_variable = tmpl.h_is_binary_variable; + fj_cpu.h_binary_indices = tmpl.h_binary_indices; + + fj_cpu.h_cstr_left_weights = left_weights; + fj_cpu.h_cstr_right_weights = right_weights; + fj_cpu.max_weight = 1.0; + fj_cpu.h_objective_weight = objective_weight; + fj_cpu.h_assignment = tmpl.h_assignment; + fj_cpu.h_best_assignment = tmpl.h_assignment; + fj_cpu.h_tabu_nodec_until.resize(problem.n_variables, 0); + fj_cpu.h_tabu_noinc_until.resize(problem.n_variables, 0); + fj_cpu.h_tabu_lastdec.resize(problem.n_variables, 0); + fj_cpu.h_tabu_lastinc.resize(problem.n_variables, 0); + fj_cpu.iterations = 0; + + finalize_fj_cpu_host_initialization_from_template(fj_cpu, + tmpl, + problem.n_variables, + problem.n_constraints, + problem.n_integer_vars, + problem.nnz, + problem.tolerances); +} + template static void set_host_data_view( fj_cpu_climber_t& fj_cpu, @@ -1612,7 +1694,7 @@ static void set_host_data_view( } template -void finalize_fj_cpu_host_initialization( +static void wire_fj_cpu_host_views( fj_cpu_climber_t& fj_cpu, i_t n_variables, i_t n_constraints, @@ -1620,8 +1702,6 @@ void finalize_fj_cpu_host_initialization( i_t nnz, const typename mip_solver_settings_t::tolerances_t& tolerances) { - raft::common::nvtx::range scope("finalize_fj_cpu_host_initialization"); - cuopt_assert(n_variables >= 0, "invalid variable count"); cuopt_assert(n_constraints >= 0, "invalid constraint count"); cuopt_assert(fj_cpu.h_offsets.size() == static_cast(n_constraints + 1), @@ -1655,6 +1735,30 @@ void finalize_fj_cpu_host_initialization( fj_cpu.view.best_objective = &fj_cpu.h_best_objective; fj_cpu.view.settings = &fj_cpu.settings; + fj_cpu.h_best_objective = +std::numeric_limits::infinity(); + + // nnz count + fj_cpu.cached_mtm_moves.resize(fj_cpu.h_coefficients.size(), + std::make_pair(0, fj_staged_score_t::zero())); + + fj_cpu.flip_move_computed.resize(n_variables, false); + fj_cpu.var_bitmap.resize(n_variables, false); + fj_cpu.iter_mtm_vars.reserve(n_variables); +} + +template +void finalize_fj_cpu_host_initialization( + fj_cpu_climber_t& fj_cpu, + i_t n_variables, + i_t n_constraints, + i_t n_integer_vars, + i_t nnz, + const typename mip_solver_settings_t::tolerances_t& tolerances) +{ + raft::common::nvtx::range scope("finalize_fj_cpu_host_initialization"); + + wire_fj_cpu_host_views(fj_cpu, n_variables, n_constraints, n_integer_vars, nnz, tolerances); + fj_cpu.h_objective_vars.resize(n_variables); auto end = std::copy_if( thrust::counting_iterator(0), @@ -1665,12 +1769,6 @@ void finalize_fj_cpu_host_initialization( fj_cpu.view.objective_vars = raft::device_span(fj_cpu.h_objective_vars.data(), fj_cpu.h_objective_vars.size()); - fj_cpu.h_best_objective = +std::numeric_limits::infinity(); - - // nnz count - fj_cpu.cached_mtm_moves.resize(fj_cpu.h_coefficients.size(), - std::make_pair(0, fj_staged_score_t::zero())); - fj_cpu.cached_cstr_bounds.resize(fj_cpu.h_reverse_coefficients.size()); for (i_t var_idx = 0; var_idx < n_variables; ++var_idx) { auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); @@ -1698,12 +1796,61 @@ void finalize_fj_cpu_host_initialization( fj_cpu.var_bitmap.resize(n_variables, false); fj_cpu.iter_mtm_vars.reserve(n_variables); + // Must precede recompute_lhs, which is what first populates them. + fj_cpu.violated_constraints.resize(n_constraints); + fj_cpu.satisfied_constraints.resize(n_constraints); + recompute_lhs(fj_cpu); // Precompute static problem features for regression model precompute_problem_features(fj_cpu); } +template +static void finalize_fj_cpu_host_initialization_from_template( + fj_cpu_climber_t& fj_cpu, + const fj_cpu_climber_t& tmpl, + i_t n_variables, + i_t n_constraints, + i_t n_integer_vars, + i_t nnz, + const typename mip_solver_settings_t::tolerances_t& tolerances) +{ + raft::common::nvtx::range scope("finalize_fj_cpu_host_initialization_from_template"); + + cuopt_assert(tmpl.h_lhs.size() == static_cast(n_constraints), "template lhs mismatch"); + cuopt_assert(tmpl.violated_constraints.max_size() == n_constraints, + "template violated set mismatch"); + cuopt_assert(tmpl.satisfied_constraints.max_size() == n_constraints, + "template satisfied set mismatch"); + cuopt_assert(tmpl.cached_cstr_bounds.size() == fj_cpu.h_reverse_coefficients.size(), + "template cached bounds mismatch"); + + fj_cpu.h_objective_vars = tmpl.h_objective_vars; + fj_cpu.cached_cstr_bounds = tmpl.cached_cstr_bounds; + + fj_cpu.h_lhs = tmpl.h_lhs; + fj_cpu.h_lhs_sumcomp = tmpl.h_lhs_sumcomp; + fj_cpu.violated_constraints = tmpl.violated_constraints; + fj_cpu.satisfied_constraints = tmpl.satisfied_constraints; + fj_cpu.total_violations = tmpl.total_violations; + fj_cpu.h_incumbent_objective = tmpl.h_incumbent_objective; + + fj_cpu.n_binary_vars = tmpl.n_binary_vars; + fj_cpu.n_integer_vars = tmpl.n_integer_vars; + fj_cpu.avg_var_degree = tmpl.avg_var_degree; + fj_cpu.max_var_degree = tmpl.max_var_degree; + fj_cpu.var_degree_cv = tmpl.var_degree_cv; + fj_cpu.avg_cstr_degree = tmpl.avg_cstr_degree; + fj_cpu.max_cstr_degree = tmpl.max_cstr_degree; + fj_cpu.cstr_degree_cv = tmpl.cstr_degree_cv; + fj_cpu.problem_density = tmpl.problem_density; + + wire_fj_cpu_host_views(fj_cpu, n_variables, n_constraints, n_integer_vars, nnz, tolerances); + fj_cpu.view.objective_vars = + raft::device_span(fj_cpu.h_objective_vars.data(), fj_cpu.h_objective_vars.size()); +} + template static std::unique_ptr> init_fj_cpu_from_host_lp( const lp_problem_t& problem, @@ -1830,7 +1977,7 @@ static void sanity_checks(fj_cpu_climber_t& fj_cpu) // Check that each violated constraint is actually violated and not present in // satisfied_constraints for (const auto& cstr_idx : fj_cpu.violated_constraints) { - cuopt_assert(fj_cpu.satisfied_constraints.count(cstr_idx) == 0, + cuopt_assert(!fj_cpu.satisfied_constraints.contains(cstr_idx), "Violated constraint also in satisfied_constraints"); f_t lhs = fj_cpu.h_lhs[cstr_idx]; f_t tol = fj_cpu.view.get_corrected_tolerance(cstr_idx); @@ -1841,7 +1988,7 @@ static void sanity_checks(fj_cpu_climber_t& fj_cpu) // Check that each satisfied constraint is actually satisfied and not present in // violated_constraints for (const auto& cstr_idx : fj_cpu.satisfied_constraints) { - cuopt_assert(fj_cpu.violated_constraints.count(cstr_idx) == 0, + cuopt_assert(!fj_cpu.violated_constraints.contains(cstr_idx), "Satisfied constraint also in violated_constraints"); f_t lhs = fj_cpu.h_lhs[cstr_idx]; f_t tol = fj_cpu.view.get_corrected_tolerance(cstr_idx); @@ -1851,8 +1998,8 @@ static void sanity_checks(fj_cpu_climber_t& fj_cpu) // Check that each constraint is in exactly one of violated_constraints or satisfied_constraints for (i_t cstr_idx = 0; cstr_idx < fj_cpu.view.pb.n_constraints; ++cstr_idx) { - bool in_viol = fj_cpu.violated_constraints.count(cstr_idx) > 0; - bool in_sat = fj_cpu.satisfied_constraints.count(cstr_idx) > 0; + bool in_viol = fj_cpu.violated_constraints.contains(cstr_idx); + bool in_sat = fj_cpu.satisfied_constraints.contains(cstr_idx); cuopt_assert( in_viol != in_sat, "Constraint must be in exactly one of violated_constraints or satisfied_constraints"); @@ -2270,6 +2417,25 @@ std::unique_ptr> init_fj_cpu_standalone( return fj_cpu; } +template +std::unique_ptr> init_fj_cpu_standalone_from_template( + problem_t& problem, + const fj_cpu_climber_t& tmpl, + std::atomic& preemption_flag, + fj_settings_t settings) +{ + raft::common::nvtx::range scope("init_fj_cpu_standalone_from_template"); + + auto fj_cpu = std::make_unique>(preemption_flag); + + std::vector default_weights(problem.n_constraints, 1.0); + init_fj_cpu_from_template(*fj_cpu, tmpl, problem, default_weights, default_weights, 0.0); + fj_cpu->settings = settings; + fj_cpu->settings.seed = cuopt::seed_generator::get_seed(); + + return fj_cpu; +} + template void fj_cpu_worker_t::fj_cpu_deleter_t::operator()(fj_cpu_climber_t* ptr) const { @@ -2347,6 +2513,11 @@ template std::unique_ptr> init_fj_cpu_standalone( solution_t& solution, std::atomic& preemption_flag, fj_settings_t settings); +template std::unique_ptr> init_fj_cpu_standalone_from_template( + problem_t& problem, + const fj_cpu_climber_t& tmpl, + std::atomic& preemption_flag, + fj_settings_t settings); template std::unique_ptr> init_fj_cpu_from_optimization_problem( const optimization_problem_t& problem, const typename mip_solver_settings_t::tolerances_t& tolerances, @@ -2372,6 +2543,11 @@ template std::unique_ptr> init_fj_cpu_standalone( solution_t& solution, std::atomic& preemption_flag, fj_settings_t settings); +template std::unique_ptr> init_fj_cpu_standalone_from_template( + problem_t& problem, + const fj_cpu_climber_t& tmpl, + std::atomic& preemption_flag, + fj_settings_t settings); template std::unique_ptr> init_fj_cpu_from_optimization_problem( const optimization_problem_t& problem, const typename mip_solver_settings_t::tolerances_t& tolerances, diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 89db669dd0..302c1a4e13 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -8,6 +8,7 @@ #pragma once #include +#include #include #include #include @@ -25,6 +26,65 @@ namespace cuopt::mathematical_optimization::mip { template class probing_cache_t; +template +struct host_contiguous_set_t { + void resize(i_t max_size) + { + cuopt_assert(max_size >= 0, "invalid max size"); + contents.clear(); + contents.reserve(max_size); + index_map.assign(max_size, -1); + is_member.assign(max_size, 0); + } + + void clear() + { + for (i_t val : contents) { + index_map[val] = -1; + is_member[val] = 0; + } + contents.clear(); + } + + void insert(i_t val) + { + cuopt_assert(val >= 0 && val < max_size(), "Value is out of bounds"); + cuopt_assert(!contains(val), "Value already exists"); + index_map[val] = contents.size(); + is_member[val] = 1; + contents.push_back(val); + } + + void remove(i_t val) + { + cuopt_assert(val >= 0 && val < max_size(), "Value is out of bounds"); + cuopt_assert(contains(val), "Value not found"); + const i_t idx = index_map[val]; + const i_t last_val = contents.back(); + contents[idx] = last_val; + index_map[last_val] = idx; + contents.pop_back(); + index_map[val] = -1; + is_member[val] = 0; + } + + bool contains(i_t val) const + { + cuopt_assert(val >= 0 && val < max_size(), "Value is out of bounds"); + return is_member[val] != 0; + } + + auto begin() const { return contents.begin(); } + auto end() const { return contents.end(); } + i_t size() const { return contents.size(); } + i_t max_size() const { return index_map.size(); } + bool empty() const { return contents.empty(); } + + std::vector contents; + std::vector index_map; + std::vector is_member; +}; + // NOTE: this seems an easy pick for reflection/xmacros once this is available (C++26?) // Maintaining a single source of truth for all members would be nice template @@ -123,8 +183,8 @@ struct fj_cpu_climber_t { f_t h_best_objective; i_t last_feasible_entrance_iter{0}; i_t iterations; - std::unordered_set violated_constraints; - std::unordered_set satisfied_constraints; + host_contiguous_set_t violated_constraints; + host_contiguous_set_t satisfied_constraints; bool feasible_found{false}; bool trigger_early_lhs_recomputation{false}; f_t total_violations{0}; @@ -232,6 +292,13 @@ std::unique_ptr> init_fj_cpu_standalone( std::atomic& preemption_flag, fj_settings_t settings = fj_settings_t{}); +template +std::unique_ptr> init_fj_cpu_standalone_from_template( + problem_t& problem, + const fj_cpu_climber_t& tmpl, + std::atomic& preemption_flag, + fj_settings_t settings = fj_settings_t{}); + template std::unique_ptr> init_fj_cpu_from_optimization_problem( const optimization_problem_t& problem, From 9d46a9f5264aa291e8d795f6f9348b1725813b27 Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 00:01:08 -0700 Subject: [PATCH 38/78] some bug fixes regarding lift moves --- .../linear_programming/cuopt/run_cpufj.cu | 3 +-- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 17 ++++++++++++ .../feasibility_jump/fj_cpu_binary.cu | 27 ++++++++++++------- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/benchmarks/linear_programming/cuopt/run_cpufj.cu b/benchmarks/linear_programming/cuopt/run_cpufj.cu index d071cc0648..d8144132d3 100644 --- a/benchmarks/linear_programming/cuopt/run_cpufj.cu +++ b/benchmarks/linear_programming/cuopt/run_cpufj.cu @@ -129,8 +129,7 @@ int main(int argc, char** argv) mip::clamp_within_var_bounds(solution.assignment, &problem, &handle); handle.sync_stream(); - // Built serially: only the first climber host-copies the problem off the stream, the rest clone - // its host data. + // Built serially: the first climber host-copies the problem, the rest clone it. std::vector> preemption_flags(n_climbers); std::vector>> climbers(n_climbers); for (int k = 0; k < n_climbers; ++k) { diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 9adce2a3f7..c86aead066 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1415,6 +1415,23 @@ static thrust::tuple find_lift_move( delta = round(1.0 - 2 * val); // flip move wouldn't improve if (delta * obj_coeff >= 0) continue; + + auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); + bool breaks_a_row = false; + for (i_t j = offset_begin; j < offset_end; ++j) { + auto [c_lb, c_ub] = fj_cpu.cached_cstr_bounds[j].get(); + const i_t cstr_idx = fj_cpu.h_reverse_constraints[j]; + const f_t cstr_coeff = fj_cpu.h_reverse_coefficients[j]; + const f_t lhs = fj_cpu.h_lhs[cstr_idx]; + const f_t sumcomp = fj_cpu.h_lhs_sumcomp[cstr_idx]; + const f_t new_lhs = lhs + (cstr_coeff * delta - sumcomp); + if (fj_cpu.view.excess_score(cstr_idx, new_lhs, c_lb, c_ub) < + -fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub)) { + breaks_a_row = true; + break; + } + } + if (breaks_a_row) continue; } else { f_t lfd_lb = get_lower(fj_cpu.h_var_bounds[var_idx].get()) - val; f_t lfd_ub = get_upper(fj_cpu.h_var_bounds[var_idx].get()) - val; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 03685a4e83..6e2e3e0215 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -578,6 +578,7 @@ struct fj_bin_engine_t { std::vector sample_buf; // move-selection row sample, reused to keep the loop allocation-free int32_t objective_weight{0}; + int32_t seed_objective_weight{0}; double incumbent_objective{0}; double best_objective{std::numeric_limits::infinity()}; int32_t max_weight{1}; @@ -1055,12 +1056,16 @@ struct fj_bin_engine_t { std::pair find_lift_move() const { + cuopt_assert(violated_list.empty(), "lift moves require a feasible incumbent"); + int32_t best_v = -1; int64_t best_s = 0; for (int32_t v : pb.objective_vars) { const int8_t delta = (int8_t)(1 - 2 * assign[v]); if ((double)delta * pb.objective[v] >= 0) continue; if (tabu_blocked(v, false)) continue; + // Base field is zero iff the flip breaks no row; K/2 splits it while |bonus| < 2^31. + if (var_score[v] <= -(fj_bin_score_k / 2)) continue; const int64_t s = (int64_t)(-std::llround(pb.objective[v] * delta)) * fj_bin_score_k; if (s > best_s) { best_s = s; @@ -1090,7 +1095,7 @@ struct fj_bin_engine_t { for (int32_t v = 0; v < pb.n_variables; ++v) assign_i32[v] = assign[v]; for (int32_t r = 0; r < pb.n_constraints; ++r) row_weight[r] = pb.initial_weight[r]; max_weight = fj_bin_ddfw_init; - objective_weight = 0; + objective_weight = seed_objective_weight; tabu.clear(iters); recompute_slack(); last_restart_iter = iters; @@ -1141,14 +1146,18 @@ struct fj_bin_engine_t { violated_list.clear(); var_bitmap.assign(n, 0); - argmax_tile = fj_bin_argmax_tile(); - objective_weight = 0; - max_weight = fj_bin_ddfw_init; - incumbent_objective = 0; - best_objective = std::numeric_limits::infinity(); - feasible_found = false; - iters = 0; - last_restart_iter = 0; + const int32_t seeded_weight = (int32_t)std::lround(climber.h_objective_weight); + cuopt_assert(seeded_weight >= 0, "objective weight should be positive or zero"); + + argmax_tile = fj_bin_argmax_tile(); + objective_weight = seeded_weight > 0 ? seeded_weight : 0; + seed_objective_weight = objective_weight; + max_weight = fj_bin_ddfw_init; + incumbent_objective = 0; + best_objective = std::numeric_limits::infinity(); + feasible_found = false; + iters = 0; + last_restart_iter = 0; recompute_slack(); } From 996c832ceb979092648b3a06842e74486a1369b4 Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 04:00:47 -0700 Subject: [PATCH 39/78] hiverge inspired improvements --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 78 ++++++++++++++++ .../feasibility_jump/fj_cpu.cuh | 14 +++ .../feasibility_jump/fj_cpu_binary.cu | 89 +++++++++++++++++++ 3 files changed, 181 insertions(+) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index c86aead066..41d456d245 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1520,6 +1520,12 @@ template static void perturb(fj_cpu_climber_t& fj_cpu) { CPUFJ_NVTX_RANGE("CPUFJ::perturb"); + if (fj_cpu.feasible_found) { + cuopt_assert(fj_cpu.h_assignment.size() == fj_cpu.h_best_assignment.size(), + "incumbent_assignment span would be invalidated"); + fj_cpu.h_assignment = fj_cpu.h_best_assignment; + } + // select N variables, assign them a random value between their bounds std::vector sampled_vars; std::sample(fj_cpu.h_objective_vars.begin(), @@ -1548,6 +1554,67 @@ static void perturb(fj_cpu_climber_t& fj_cpu) recompute_lhs(fj_cpu); } +template +static void reset_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) +{ + fj_cpu.h_best_infeasible_assignment.clear(); + fj_cpu.best_infeasible_severity = std::numeric_limits::infinity(); + fj_cpu.checkpoint_severity = std::numeric_limits::infinity(); + fj_cpu.iters_since_infeasible_improve = 0; +} + +template +static void restart_from_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) +{ + cuopt_assert(fj_cpu.h_assignment.size() == fj_cpu.h_best_infeasible_assignment.size(), + "incumbent_assignment span would be invalidated"); + fj_cpu.h_assignment = fj_cpu.h_best_infeasible_assignment; + recompute_lhs(fj_cpu); + for (size_t i = 0; i < fj_cpu.cached_mtm_moves.size(); ++i) + fj_cpu.cached_mtm_moves[i].first = 0; +} + +template +static void track_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) +{ + CPUFJ_NVTX_RANGE("CPUFJ::track_infeasible_checkpoint"); + if (fj_cpu.violated_constraints.empty()) { + reset_infeasible_checkpoint(fj_cpu); + return; + } + + const f_t severity = -fj_cpu.total_violations; + cuopt_assert(severity >= 0, "violation severity should be positive or zero"); + + if (severity < fj_cpu.best_infeasible_severity) { + fj_cpu.best_infeasible_severity = severity; + fj_cpu.iters_since_infeasible_improve = 0; + fj_cpu.restores_since_improvement = 0; + if (severity < fj_cpu.checkpoint_severity * fj_cpu.infeasible_checkpoint_refresh_ratio) { + fj_cpu.h_best_infeasible_assignment = fj_cpu.h_assignment; + fj_cpu.checkpoint_severity = severity; + ++fj_cpu.n_checkpoint_snapshots; + } + return; + } + + if (fj_cpu.restores_since_improvement >= fj_cpu.infeasible_restart_max_streak) return; + if (++fj_cpu.iters_since_infeasible_improve < fj_cpu.infeasible_restart_window) return; + if (severity <= fj_cpu.best_infeasible_severity * fj_cpu.infeasible_restart_degrade_ratio) return; + if (fj_cpu.h_best_infeasible_assignment.empty()) return; + + cuopt_assert(fj_cpu.checkpoint_severity >= fj_cpu.best_infeasible_severity, + "checkpoint cannot beat the best severity seen"); + + restart_from_infeasible_checkpoint(fj_cpu); + + ++fj_cpu.n_checkpoint_restores; + ++fj_cpu.restores_since_improvement; + if (fj_cpu.restores_since_improvement > fj_cpu.max_restores_since_improvement) + fj_cpu.max_restores_since_improvement = fj_cpu.restores_since_improvement; + fj_cpu.iters_since_infeasible_improve = 0; +} + template static void init_fj_cpu(fj_cpu_climber_t& fj_cpu, solution_t& solution, @@ -2073,6 +2140,11 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w fj_cpu->last_feature_log_time = loop_start; fj_cpu->prev_best_objective = fj_cpu->h_best_objective; fj_cpu->iterations_since_best = 0; + reset_infeasible_checkpoint(*fj_cpu); + fj_cpu->n_checkpoint_restores = 0; + fj_cpu->n_checkpoint_snapshots = 0; + fj_cpu->restores_since_improvement = 0; + fj_cpu->max_restores_since_improvement = 0; while (!fj_cpu->halted && !fj_cpu->preemption_flag.load()) { // Check if 5 seconds have passed @@ -2142,6 +2214,7 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w } else { // Local Min update_weights(*fj_cpu); + track_infeasible_checkpoint(*fj_cpu); if (should_perturb) { perturb(*fj_cpu); for (size_t i = 0; i < fj_cpu->cached_mtm_moves.size(); i++) @@ -2229,6 +2302,11 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w CUOPT_LOG_TRACE("%sCPUFJ Average time per iteration: %.8fms", fj_cpu->log_prefix.c_str(), avg_time_per_iter * 1000.0); + CUOPT_LOG_DEBUG("%sCPUFJ checkpoint: %lld restores, %lld snapshots, max streak %d", + fj_cpu->log_prefix.c_str(), + (long long)fj_cpu->n_checkpoint_restores, + (long long)fj_cpu->n_checkpoint_snapshots, + fj_cpu->max_restores_since_improvement); #if CPUFJ_TIMING_TRACE // Print final timing statistics diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 302c1a4e13..37505682c9 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -125,6 +125,7 @@ struct fj_cpu_climber_t { ADD_INSTRUMENTED(h_cstr_right_weights), ADD_INSTRUMENTED(h_assignment), ADD_INSTRUMENTED(h_best_assignment), + ADD_INSTRUMENTED(h_best_infeasible_assignment), ADD_INSTRUMENTED(cached_cstr_bounds), ADD_INSTRUMENTED(iter_mtm_vars)}; @@ -222,10 +223,23 @@ struct fj_cpu_climber_t { std::vector> two_opt_partners; std::vector> two_opt_row_deltas; + ins_vector h_best_infeasible_assignment; + f_t best_infeasible_severity{std::numeric_limits::infinity()}; + f_t checkpoint_severity{std::numeric_limits::infinity()}; + i_t iters_since_infeasible_improve{0}; + i_t restores_since_improvement{0}; + i_t max_restores_since_improvement{0}; + int64_t n_checkpoint_restores{0}; + int64_t n_checkpoint_snapshots{0}; + i_t mtm_viol_samples{25}; i_t mtm_sat_samples{15}; i_t nnz_samples{50000}; i_t perturb_interval{100}; + i_t infeasible_restart_window{300}; + i_t infeasible_restart_max_streak{20}; + f_t infeasible_restart_degrade_ratio{1.15}; + f_t infeasible_checkpoint_refresh_ratio{0.99}; i_t log_interval{1000}; i_t diversity_callback_interval{3000}; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 6e2e3e0215..c234c69186 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -559,6 +559,12 @@ struct fj_bin_engine_t { std::vector seed_assign; // restart target std::vector assign_i32; // gather mirror for the SIMD patch (Batch B) + std::vector best_infeasible_assign; + int64_t best_infeasible_severity{std::numeric_limits::max()}; + int64_t checkpoint_severity{std::numeric_limits::max()}; + int32_t iters_since_infeasible_improve{0}; + int32_t restores_since_improvement{0}; + std::vector var_score; // live feasibility score of flipping each variable std::vector nnz_score_delta; // per CSR nnz: last score delta of variables[k] in its row @@ -594,6 +600,10 @@ struct fj_bin_engine_t { int64_t nnz_patched{0}; int64_t rows_walked{0}; + int64_t n_checkpoint_restores{0}; + int64_t n_checkpoint_snapshots{0}; + int32_t max_restores_since_improvement{0}; + // Tile width for the argmax sweep, in variables. Set at init from fj_bin_argmax_tile(). int32_t argmax_tile{fj_bin_argmax_tile_target}; @@ -604,6 +614,10 @@ struct fj_bin_engine_t { int32_t perturb_interval{100}; int32_t mtm_viol_samples{25}; int32_t mtm_sat_samples{15}; + int32_t infeasible_restart_window{300}; + int32_t infeasible_restart_max_streak{20}; + double infeasible_restart_degrade_ratio{1.15}; + double infeasible_checkpoint_refresh_ratio{0.99}; double breakthrough_margin{1e-4}; int32_t max_aggregate_base{0}; @@ -948,6 +962,61 @@ struct fj_bin_engine_t { reweight_constraint(best_donor, row_weight[best_donor] - fj_bin_ddfw_transfer); } if (violated_list.empty()) objective_weight += 1; + track_infeasible_checkpoint(); + } + + void reset_infeasible_checkpoint() + { + best_infeasible_assign.clear(); + best_infeasible_severity = std::numeric_limits::max(); + checkpoint_severity = std::numeric_limits::max(); + iters_since_infeasible_improve = 0; + } + + void track_infeasible_checkpoint() + { + if (violated_list.empty()) { + reset_infeasible_checkpoint(); + return; + } + + int64_t severity = 0; + for (int32_t r : violated_list) { + cuopt_assert(row_slack[r] < 0, "row in violated_list is not violated"); + severity -= (int64_t)row_slack[r]; + } + + if (severity < best_infeasible_severity) { + best_infeasible_severity = severity; + iters_since_infeasible_improve = 0; + restores_since_improvement = 0; + if ((double)severity < (double)checkpoint_severity * infeasible_checkpoint_refresh_ratio) { + best_infeasible_assign = assign; + checkpoint_severity = severity; + ++n_checkpoint_snapshots; + } + return; + } + + if (restores_since_improvement >= infeasible_restart_max_streak) return; + if (++iters_since_infeasible_improve < infeasible_restart_window) return; + if ((double)severity <= (double)best_infeasible_severity * infeasible_restart_degrade_ratio) + return; + if (best_infeasible_assign.empty()) return; + + cuopt_assert(checkpoint_severity >= best_infeasible_severity, + "checkpoint cannot beat the best severity seen"); + + assign = best_infeasible_assign; + for (int32_t v = 0; v < pb.n_variables; ++v) + assign_i32[v] = assign[v]; + recompute_slack(); + + ++n_checkpoint_restores; + ++restores_since_improvement; + if (restores_since_improvement > max_restores_since_improvement) + max_restores_since_improvement = restores_since_improvement; + iters_since_infeasible_improve = 0; } // Global argmax over every variable, affordable because var_score is maintained live. While the @@ -1096,6 +1165,7 @@ struct fj_bin_engine_t { for (int32_t r = 0; r < pb.n_constraints; ++r) row_weight[r] = pb.initial_weight[r]; max_weight = fj_bin_ddfw_init; objective_weight = seed_objective_weight; + reset_infeasible_checkpoint(); tabu.clear(iters); recompute_slack(); last_restart_iter = iters; @@ -1113,6 +1183,18 @@ struct fj_bin_engine_t { perturb_interval = climber.perturb_interval; mtm_viol_samples = climber.mtm_viol_samples; mtm_sat_samples = climber.mtm_sat_samples; + + infeasible_restart_window = climber.infeasible_restart_window; + infeasible_restart_max_streak = climber.infeasible_restart_max_streak; + infeasible_restart_degrade_ratio = (double)climber.infeasible_restart_degrade_ratio; + infeasible_checkpoint_refresh_ratio = (double)climber.infeasible_checkpoint_refresh_ratio; + cuopt_assert(infeasible_restart_window > 0, "invalid infeasible restart window"); + cuopt_assert(infeasible_restart_max_streak > 0, "invalid infeasible restart streak cap"); + cuopt_assert(infeasible_restart_degrade_ratio >= 1.0, "degrade ratio should be at least one"); + cuopt_assert( + infeasible_checkpoint_refresh_ratio > 0.0 && infeasible_checkpoint_refresh_ratio <= 1.0, + "checkpoint refresh ratio should be in (0, 1]"); + if (tabu_tenure_max <= tabu_tenure_min) tabu_tenure_max = tabu_tenure_min + 1; // The tabu ring is indexed by iteration modulo its size, so a slot is reused after ring_size @@ -1132,6 +1214,7 @@ struct fj_bin_engine_t { } seed_assign = assign; best_assign = assign; + reset_infeasible_checkpoint(); assign_i32.assign(n, 0); for (int32_t v = 0; v < n; ++v) assign_i32[v] = assign[v]; @@ -1244,6 +1327,12 @@ struct fj_bin_engine_t { coefficient_bits(), (long long)nnz_patched, (long long)rows_walked); + CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] checkpoint: %lld restores, %lld snapshots, max streak %d", + climber.log_prefix.c_str(), + coefficient_bits(), + (long long)n_checkpoint_restores, + (long long)n_checkpoint_snapshots, + max_restores_since_improvement); } }; From 075ded0c5263405a4544f182ee3792c839a6170e Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 04:44:09 -0700 Subject: [PATCH 40/78] ddfw improvements --- .../feasibility_jump/fj_cpu_binary.cu | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index c234c69186..536254bb31 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -199,9 +199,13 @@ constexpr int64_t fj_bin_scale_cap = std::numeric_limits::max(); // reason to promote them alongside the other FJ knobs. constexpr int32_t fj_bin_ddfw_init = 10; // initial weight, also the donation floor constexpr int32_t fj_bin_ddfw_transfer = 1; -constexpr int32_t fj_bin_ddfw_donor_samples = 1; +constexpr int32_t fj_bin_ddfw_donor_samples = 4; constexpr int32_t fj_bin_restart_period = 5000000; +// Escalation threshold and step, in infeasible local minima without a severity improvement. +constexpr int32_t fj_bin_ddfw_escalate_after = 2000; +constexpr int32_t fj_bin_ddfw_escalate_max = 100; + // prefetch distance // TODO: check if it actually matters at all for performance constexpr int32_t fj_bin_pf_dist = 8; @@ -940,13 +944,27 @@ struct fj_bin_engine_t { // DDFW: every violated row gains weight taken from a satisfied neighbour above the donation // floor, so total weight is roughly conserved and differentiation stays local to the hard region. + // Unit transfers stop moving the landscape on a long stall, so the amount grows with the stall. + int32_t ddfw_transfer() const + { + if (iters_since_infeasible_improve <= fj_bin_ddfw_escalate_after) return fj_bin_ddfw_transfer; + const int32_t over = iters_since_infeasible_improve - fj_bin_ddfw_escalate_after; + const int32_t steps = over / fj_bin_ddfw_escalate_after + 1; + const int32_t scale = steps < fj_bin_ddfw_escalate_max ? steps : fj_bin_ddfw_escalate_max; + return fj_bin_ddfw_transfer * scale; + } + void update_weights() { + const int32_t transfer = ddfw_transfer(); + // Donors must stay above the floor, or weights go negative and every base score inverts. + const int32_t donor_floor = fj_bin_ddfw_init + transfer - 1; + for (int32_t cf : violated_list) { - reweight_constraint(cf, row_weight[cf] + fj_bin_ddfw_transfer); + reweight_constraint(cf, row_weight[cf] + transfer); const int32_t vo = pb.offsets[cf], ve = pb.offsets[cf + 1]; if (ve <= vo) continue; - int32_t best_donor = -1, best_w = fj_bin_ddfw_init; + int32_t best_donor = -1, best_w = donor_floor; for (int32_t s = 0; s < fj_bin_ddfw_donor_samples; ++s) { const int32_t v = pb.variables[vo + (int32_t)(rng.next_u32() % (uint32_t)(ve - vo))]; const int32_t no = pb.reverse_offsets[v], ne = pb.reverse_offsets[v + 1]; @@ -958,8 +976,11 @@ struct fj_bin_engine_t { best_donor = d; } } - if (best_donor >= 0) - reweight_constraint(best_donor, row_weight[best_donor] - fj_bin_ddfw_transfer); + if (best_donor >= 0) { + const int32_t donated = row_weight[best_donor] - transfer; + cuopt_assert(donated >= fj_bin_ddfw_init, "donation broke the weight floor"); + reweight_constraint(best_donor, donated); + } } if (violated_list.empty()) objective_weight += 1; track_infeasible_checkpoint(); From cdc805b7b49623b8c682bdc32ea79f85e4f3e21b Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 06:16:08 -0700 Subject: [PATCH 41/78] changes for the hiverge harness --- .../linear_programming/cuopt/run_cpufj.cu | 60 ++++++++++++------- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 47 +++++++++++++++ .../feasibility_jump/fj_cpu.cuh | 12 ++++ cpp/src/mip_heuristics/solution/solution.cu | 26 ++++++++ cpp/src/mip_heuristics/solution/solution.cuh | 9 +++ 5 files changed, 131 insertions(+), 23 deletions(-) diff --git a/benchmarks/linear_programming/cuopt/run_cpufj.cu b/benchmarks/linear_programming/cuopt/run_cpufj.cu index d8144132d3..781c28236c 100644 --- a/benchmarks/linear_programming/cuopt/run_cpufj.cu +++ b/benchmarks/linear_programming/cuopt/run_cpufj.cu @@ -116,40 +116,54 @@ int main(int argc, char** argv) cuopt::mathematical_optimization::mps_data_model_to_optimization_problem( &handle, mps_data_model); mip::problem_t problem(op_problem); + + // Anonymise the instance before anything under evolution can see it. + // + // problem_t exposes var_names, row_names and objective_name as public members, and + // the FJ code receives problem_t&. For a fixed benchmark set those strings are an + // exact fingerprint -- row_names[0] alone identifies most MIPLIB instances -- so a + // candidate could branch on identity and return a memorised objective. Reading the + // MODEL is intended and useful: coefficients, bounds, variable types, sparsity and + // row structure are all untouched here, so recognising set-packing rows, knapsack + // substructure or GUB constraints still works exactly as before. Only the labels go. + // + // Each string is cleared in place rather than the vectors being emptied, so size() + // and indexing stay valid and any code that walks names by variable index still + // works -- it just gets empty strings. + // + // This file is outside target_code and is sha256-gated by evaluate.py's FROZEN_FILES, + // so a candidate cannot restore the names. Do not move this below the solve. + for (auto& name : problem.var_names) name.clear(); + for (auto& name : problem.row_names) name.clear(); + problem.objective_name.clear(); + std::printf("instance: %s n_vars=%d n_cstrs=%d nnz=%d\n", path.c_str(), problem.n_variables, problem.n_constraints, problem.nnz); - // Zero start, clamped into the variable bounds. Shared by every climber; diversity comes from - // the per-climber seed and sampling parameters below. + // FROZEN -- defines t=0 for the benchmark. Everything above it (the MPS parse, + // problem construction under problem/, and the name anonymisation) is outside + // target_code; everything below it is editable. A marker any later would leave + // editable code ahead of the clock, which is somewhere to do unmeasured work; any + // earlier would charge the budget for a parse and a CUDA context no candidate can + // influence. + CUOPT_LOG_INFO("CPUFJ solve window start"); + + // Shared by every climber. Built by build_start_assignment, which is editable -- + // this driver is not. mip::solution_t solution(problem); - thrust::fill(handle.get_thrust_policy(), solution.assignment.begin(), solution.assignment.end(), f_t{0}); - mip::clamp_within_var_bounds(solution.assignment, &problem, &handle); - handle.sync_stream(); + mip::build_start_assignment(problem, solution, &handle); - // Built serially: the first climber host-copies the problem, the rest clone it. std::vector> preemption_flags(n_climbers); std::vector>> climbers(n_climbers); + // Composition and per-climber parameters come from build_climber_portfolio, which + // is editable. The log prefix is assigned here and not there, so every climber + // stays identifiable in the log whatever the portfolio does. + mip::build_climber_portfolio(problem, solution, preemption_flags, climbers, base_seed); for (int k = 0; k < n_climbers; ++k) { - preemption_flags[k].store(false); - mip::fj_settings_t settings; - settings.seed = (int)(base_seed + k); - if (k == 0) { - climbers[k] = mip::init_fj_cpu_standalone(problem, solution, preemption_flags[k], settings); - } else { - climbers[k] = mip::init_fj_cpu_standalone_from_template( - problem, *climbers[0], preemption_flags[k], settings); - } - - // Portfolio diversification, decorrelated from the value RNG. - std::mt19937 rng(base_seed + 7919u * k); - climbers[k]->mtm_viol_samples = std::uniform_int_distribution(15, 50)(rng); - climbers[k]->mtm_sat_samples = std::uniform_int_distribution(10, 30)(rng); - climbers[k]->nnz_samples = std::uniform_int_distribution(2000, 15000)(rng); - climbers[k]->perturb_interval = std::uniform_int_distribution(50, 500)(rng); - climbers[k]->log_prefix = "[climber " + std::to_string(k) + "] "; + climbers[k]->log_prefix = "[climber " + std::to_string(k) + "] "; } const std::vector cpus = allowed_cpus(); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 41d456d245..5c48cab1e3 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -2657,4 +2657,51 @@ template void finalize_fj_cpu_host_initialization( const typename mip_solver_settings_t::tolerances_t& tolerances); #endif +// Portfolio construction for the standalone benchmark. Host logic, but it lives +// in a .cu because fj_cpu.cuh pulls in raft/util/cuda_dev_essentials.cuh through +// solution.cuh, which does not compile under the host compiler. Kept out of the +// header regardless: editing this file rebuilds one translation unit rather than +// the fifteen that including headers pull in. +template +void build_climber_portfolio(problem_t& problem, + solution_t& solution, + std::vector>& preemption_flags, + std::vector>>& climbers, + int64_t base_seed) +{ + const int n_climbers = static_cast(climbers.size()); + for (int k = 0; k < n_climbers; ++k) { + preemption_flags[k].store(false); + fj_settings_t settings; + settings.seed = (int)(base_seed + k); + // Built serially: the first climber host-copies the problem, the rest clone it. + if (k == 0) { + climbers[k] = init_fj_cpu_standalone(problem, solution, preemption_flags[k], settings); + } else { + climbers[k] = + init_fj_cpu_standalone_from_template(problem, *climbers[0], preemption_flags[k], settings); + } + + // Default: every climber identical apart from its seed and a random draw of the + // four sampling parameters. Diversification, decorrelated from the value RNG. + std::mt19937 rng(base_seed + 7919u * k); + climbers[k]->mtm_viol_samples = std::uniform_int_distribution(15, 50)(rng); + climbers[k]->mtm_sat_samples = std::uniform_int_distribution(10, 30)(rng); + climbers[k]->nnz_samples = std::uniform_int_distribution(2000, 15000)(rng); + climbers[k]->perturb_interval = std::uniform_int_distribution(50, 500)(rng); + } +} + +#if MIP_INSTANTIATE_FLOAT +template void build_climber_portfolio( + problem_t&, solution_t&, std::vector>&, + std::vector>>&, int64_t); +#endif + +#if MIP_INSTANTIATE_DOUBLE +template void build_climber_portfolio( + problem_t&, solution_t&, std::vector>&, + std::vector>>&, int64_t); +#endif + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 37505682c9..53de8ba2d4 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -313,6 +313,18 @@ std::unique_ptr> init_fj_cpu_standalone_from_template std::atomic& preemption_flag, fj_settings_t settings = fj_settings_t{}); +// Builds the climber portfolio the standalone benchmark races: how many distinct +// behaviours, what parameters each gets, whether they are randomized or +// specialized. Defined in fj_cpu_portfolio.cpp -- host code, compiled by the host +// compiler, so editing it is markedly cheaper than editing this header. Runs +// inside the measured window. +template +void build_climber_portfolio(problem_t& problem, + solution_t& solution, + std::vector>& preemption_flags, + std::vector>>& climbers, + int64_t base_seed); + template std::unique_ptr> init_fj_cpu_from_optimization_problem( const optimization_problem_t& problem, diff --git a/cpp/src/mip_heuristics/solution/solution.cu b/cpp/src/mip_heuristics/solution/solution.cu index 3b00fca7a8..197db0627c 100644 --- a/cpp/src/mip_heuristics/solution/solution.cu +++ b/cpp/src/mip_heuristics/solution/solution.cu @@ -5,6 +5,7 @@ */ /* clang-format on */ +#include #include "feasibility_test.cuh" #include "solution.cuh" #include "solution_kernels.cuh" @@ -652,4 +653,29 @@ template class solution_t; template class solution_t; #endif +template +void build_start_assignment(problem_t& problem, + solution_t& solution, + const raft::handle_t* handle_ptr) +{ + // Default: zero, projected into the variable bounds. Deliberately the simplest + // thing that works -- the seeding strategy is what this hook exists to change. + thrust::fill(handle_ptr->get_thrust_policy(), + solution.assignment.begin(), + solution.assignment.end(), + f_t{0}); + clamp_within_var_bounds(solution.assignment, &problem, handle_ptr); + handle_ptr->sync_stream(); +} + +#if MIP_INSTANTIATE_FLOAT +template void build_start_assignment( + problem_t&, solution_t&, const raft::handle_t*); +#endif + +#if MIP_INSTANTIATE_DOUBLE +template void build_start_assignment( + problem_t&, solution_t&, const raft::handle_t*); +#endif + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/solution/solution.cuh b/cpp/src/mip_heuristics/solution/solution.cuh index f243937d3e..4839ba1fb8 100644 --- a/cpp/src/mip_heuristics/solution/solution.cuh +++ b/cpp/src/mip_heuristics/solution/solution.cuh @@ -153,4 +153,13 @@ class solution_t { void test_variable_bounds(bool check_integer = true, i_t* is_feasible = nullptr); }; +// Builds the start assignment every climber is derived from. Defined in +// solution.cu, so editing it recompiles one translation unit rather than every +// file that includes this header. Runs inside the measured window: a better start +// has to be worth what it costs to build. +template +void build_start_assignment(problem_t& problem, + solution_t& solution, + const raft::handle_t* handle_ptr); + } // namespace cuopt::mathematical_optimization::mip From 25e4479b413f18a8ed8e7d9bbf9afcf1f21bf91f Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 07:04:48 -0700 Subject: [PATCH 42/78] replace operator overloaded arithmetic with explicit highway functions, + some fusing --- .../fj_cpu_binary_kernels.cpp | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp index 915a98f9d4..f140041ac6 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp @@ -98,7 +98,8 @@ int32_t WalkRowsImpl(int32_t* HWY_RESTRICT row_slack, const V cmax = hn::PromoteTo(d, hn::LoadU(dc, incident_row_cmax + ii)); const V os = hn::MaskedGatherIndex(active, d, row_slack, rows); - const V ns = hn::Sub(os, hn::Mul(skv, vdelta)); + // os - skv * vdelta + const V ns = hn::NegMulAdd(skv, vdelta, os); // Only the satisfied side. deep_viol is the caller's business: it fires on 0.02% of visits but // guards the widest rows in the matrix, so it belongs where the row length is already known. @@ -178,9 +179,8 @@ static HWY_INLINE void PatchRowBody(D d, int32_t os_new, int32_t skip_var) { - const hn::Rebind dc; // same lane count, narrower lanes + const hn::Rebind dc; // same lane count, narrower lanes const hn::Repartition dw; // half the lanes, twice as wide: the packed score - const hn::Half dh; // int32 half, the source of each promotion using V = hn::Vec; using VW = hn::Vec; const size_t N = hn::Lanes(d); @@ -201,9 +201,10 @@ static HWY_INLINE void PatchRowBody(D d, const V vos = hn::Set(d, os_new); const V vw = hn::Set(d, weight), vw2 = hn::Set(d, weight / 2); - // The row's own slack is uniform across lanes, so its flags are scalars. + // The row's own slack is uniform across lanes, so its flags are scalars. Broadcast negated to + // match the new-state flags below, which come from VecFromMask and are 0 or -1. const int32_t osat = os_new >= 0, ost = os_new > 0; - const V vosat = hn::Set(d, osat), vost = hn::Set(d, ost); + const V vneg_osat = hn::Set(d, -osat), vneg_ost = hn::Set(d, -ost); const V v_not_osat = hn::Set(d, 1 - osat); // The loads always run unmasked and read into the per-nnz padding; when the remainder is masked, @@ -222,19 +223,27 @@ static HWY_INLINE void PatchRowBody(D d, // which cannot store-to-load forward, and that cost 959 interlocks per iteration against 72. // The score update escapes this because it already needs the spill for its read-modify-write. const V a01 = hn::MaskedGatherIndex(active, d, assign_i32, v); - const V flip = vone - hn::ShiftLeft<1>(a01); + const V flip = hn::Sub(vone, hn::ShiftLeft<1>(a01)); const V coef = hn::PromoteTo(d, hn::LoadU(dc, coefficients + k)); - const V ns = vos - coef * flip; + // vos - coef * flip + const V ns = hn::NegMulAdd(coef, flip, vos); - const V nsat = hn::IfThenElseZero(hn::Ge(ns, vzero), vone); - const V nst = hn::IfThenElseZero(hn::Gt(ns, vzero), vone); + // -(ns >= 0) + const V nsat_neg = hn::VecFromMask(d, hn::Ge(ns, vzero)); + // -(ns > 0) + const V nst_neg = hn::VecFromMask(d, hn::Gt(ns, vzero)); + // (ns > vos) - (ns < vos) const V improving = - hn::IfThenElseZero(hn::Gt(ns, vos), vone) - hn::IfThenElseZero(hn::Lt(ns, vos), vone); + hn::Sub(hn::VecFromMask(d, hn::Lt(ns, vos)), hn::VecFromMask(d, hn::Gt(ns, vos))); - const V both_violated = v_not_osat * (vone - nsat); - const V base = vw * (nsat - vosat) + both_violated * improving * vw2; - const V bonus = vw * (nst - vost); + // (1 - osat) * (1 - nsat) + const V both_violated = hn::Mul(v_not_osat, hn::Add(vone, nsat_neg)); + // vw * (nsat - osat) + both_violated * improving * vw2 + const V base = + hn::MulAdd(vw, hn::Sub(vneg_osat, nsat_neg), hn::Mul(hn::Mul(both_violated, improving), vw2)); + // vw * (nst - ost) + const V bonus = hn::Mul(vw, hn::Sub(vneg_ost, nst_neg)); // The score is int64, so packing it costs two vectors where the fields took one. Both fields // are per-row here and fit int32, so they are computed at full lane count above and widened @@ -242,23 +251,23 @@ static HWY_INLINE void PatchRowBody(D d, // difference and the store back. What reaches the scalar loop is one add per nonzero, which is // what it was before the score widened -- that loop is 38% of all cycles, so work belongs // anywhere but there. - const VW base_lo = hn::PromoteTo(dw, hn::LowerHalf(dh, base)); - const VW base_hi = hn::PromoteTo(dw, hn::UpperHalf(dh, base)); - const VW bonus_lo = hn::PromoteTo(dw, hn::LowerHalf(dh, bonus)); - const VW bonus_hi = hn::PromoteTo(dw, hn::UpperHalf(dh, bonus)); + const VW base_lo = hn::PromoteLowerTo(dw, base); + const VW base_hi = hn::PromoteUpperTo(dw, base); + const VW bonus_lo = hn::PromoteLowerTo(dw, bonus); + const VW bonus_hi = hn::PromoteUpperTo(dw, bonus); - const VW packed_lo = hn::ShiftLeft(base_lo) + bonus_lo; - const VW packed_hi = hn::ShiftLeft(base_hi) + bonus_hi; + const VW packed_lo = hn::Add(hn::ShiftLeft(base_lo), bonus_lo); + const VW packed_hi = hn::Add(hn::ShiftLeft(base_hi), bonus_hi); - const VW delta_lo = packed_lo - hn::LoadU(dw, nnz_score_delta + k); - const VW delta_hi = packed_hi - hn::LoadU(dw, nnz_score_delta + k + NW); + const VW delta_lo = hn::Sub(packed_lo, hn::LoadU(dw, nnz_score_delta + k)); + const VW delta_hi = hn::Sub(packed_hi, hn::LoadU(dw, nnz_score_delta + k + NW)); // The store mask is rebuilt at int64 width rather than narrowed from `active`: the same two // conditions, on the promoted indices. FirstN is applied on every target because where the // remainder is peeled the body never runs short, so it is all-true there anyway. const size_t rem = (size_t)(ke - k); - const VW v_lo = hn::PromoteTo(dw, hn::LowerHalf(dh, v)); - const VW v_hi = hn::PromoteTo(dw, hn::UpperHalf(dh, v)); + const VW v_lo = hn::PromoteLowerTo(dw, v); + const VW v_hi = hn::PromoteUpperTo(dw, v); const VW vskip_w = hn::Set(dw, skip_var); const auto act_lo = hn::And(hn::Ne(v_lo, vskip_w), hn::FirstN(dw, rem)); const auto act_hi = hn::And(hn::Ne(v_hi, vskip_w), hn::FirstN(dw, rem > NW ? rem - NW : 0)); @@ -285,8 +294,8 @@ static HWY_INLINE void PatchRowBody(D d, // indices, in the two halves the pack already produced. const VW cur_lo = hn::MaskedGatherIndex(act_lo, dw, var_score, v_lo); const VW cur_hi = hn::MaskedGatherIndex(act_hi, dw, var_score, v_hi); - hn::MaskedScatterIndex(cur_lo + delta_lo, act_lo, dw, var_score, v_lo); - hn::MaskedScatterIndex(cur_hi + delta_hi, act_hi, dw, var_score, v_hi); + hn::MaskedScatterIndex(hn::Add(cur_lo, delta_lo), act_lo, dw, var_score, v_lo); + hn::MaskedScatterIndex(hn::Add(cur_hi, delta_hi), act_hi, dw, var_score, v_hi); #endif } From 71d07a612b4969de7571cf1391bbb6523d5ddb66 Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 08:21:03 -0700 Subject: [PATCH 43/78] save user_callbacks in run_mip --- .../linear_programming/cuopt/run_mip.cpp | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/benchmarks/linear_programming/cuopt/run_mip.cpp b/benchmarks/linear_programming/cuopt/run_mip.cpp index 98cd9a56d2..207c553dcb 100644 --- a/benchmarks/linear_programming/cuopt/run_mip.cpp +++ b/benchmarks/linear_programming/cuopt/run_mip.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -136,6 +137,52 @@ std::vector> read_solution_from_dir(const std::string file_p return initial_solutions; } +struct incumbent_record_t { + double objective; + double work_timestamp; + double wall_time; +}; + +class incumbent_tracker_t : public cuopt::internals::get_solution_callback_t { + public: + explicit incumbent_tracker_t(std::chrono::high_resolution_clock::time_point start_time) + : start_time_(start_time) + { + } + + void get_solution(void* /*data*/, + void* cost, + void* /*solution_bound*/, + void* /*user_data*/) override + { + const auto now = std::chrono::high_resolution_clock::now(); + records_.push_back({*static_cast(cost), + 0.0, + std::chrono::duration(now - start_time_).count()}); + } + + void write_csv(const std::string& path) const + { + std::ofstream file(path); + if (!file.is_open()) { + std::cerr << "Error opening incumbent file " << path << std::endl; + return; + } + file << "index,objective,work_timestamp,wall_time_s\n"; + for (size_t i = 0; i < records_.size(); ++i) { + file << i << "," << std::setprecision(15) << records_[i].objective << "," + << records_[i].work_timestamp << "," << std::setprecision(6) << records_[i].wall_time + << "\n"; + } + } + + size_t size() const { return records_.size(); } + + private: + std::chrono::high_resolution_clock::time_point start_time_; + std::vector records_; +}; + int run_single_file(std::string file_path, int device, int batch_id, @@ -218,6 +265,8 @@ int run_single_file(std::string file_path, cuopt::mathematical_optimization::benchmark_info_t benchmark_info; settings.benchmark_info_ptr = &benchmark_info; auto start_run_solver = std::chrono::high_resolution_clock::now(); + incumbent_tracker_t incumbent_tracker(start_run_solver); + settings.set_mip_callback(&incumbent_tracker); auto solution = cuopt::mathematical_optimization::solve_mip(&handle_, mps_data_model, settings); CUOPT_LOG_INFO( "first obj: %f last improvement of best feasible: %f last improvement after recombination: %f", @@ -291,6 +340,13 @@ int run_single_file(std::string file_path, << "\n"; write_to_output_file(out_dir, base_filename, device, n_gpus, batch_id, ss.str()); CUOPT_LOG_INFO("Results written to the file %s", base_filename.c_str()); + if (out_dir != "") { + std::string csv_path = + out_dir + "/" + base_filename.substr(0, base_filename.find(".mps")) + "_incumbents.csv"; + incumbent_tracker.write_csv(csv_path); + CUOPT_LOG_INFO( + "Incumbent trace (%zu entries) written to %s", incumbent_tracker.size(), csv_path.c_str()); + } return sol_found; } From 27bae2f163c8e51f083e14111e764337a9f80df6 Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 09:08:17 -0700 Subject: [PATCH 44/78] let B&B publih solutions via the user callbacks as well to reduce latency --- cpp/src/branch_and_bound/branch_and_bound.cpp | 16 +- cpp/src/branch_and_bound/branch_and_bound.hpp | 8 +- .../mip_heuristics/diversity/population.cu | 50 +------ .../mip_heuristics/diversity/population.cuh | 3 - .../mip_heuristics/problem/presolve_data.cu | 15 +- .../mip_heuristics/problem/presolve_data.cuh | 4 +- cpp/src/mip_heuristics/problem/problem.cu | 5 +- cpp/src/mip_heuristics/problem/problem.cuh | 7 +- .../mip_heuristics/solution_publication.cuh | 141 ++++++++++++++++++ cpp/src/mip_heuristics/solver.cu | 36 ++--- cpp/src/mip_heuristics/solver_context.cuh | 3 + 11 files changed, 201 insertions(+), 87 deletions(-) create mode 100644 cpp/src/mip_heuristics/solution_publication.cuh diff --git a/cpp/src/branch_and_bound/branch_and_bound.cpp b/cpp/src/branch_and_bound/branch_and_bound.cpp index 29174148b7..c532e37918 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.cpp +++ b/cpp/src/branch_and_bound/branch_and_bound.cpp @@ -524,7 +524,8 @@ void branch_and_bound_t::update_user_bound(f_t lower_bound) template bool branch_and_bound_t::set_solution_from_heuristics(const std::vector& solution, - heuristics_origin_t origin) + heuristics_origin_t origin, + f_t* solver_objective) { mutex_original_lp_.lock(); if (solution.size() != original_problem_.num_cols) { @@ -537,6 +538,7 @@ bool branch_and_bound_t::set_solution_from_heuristics(const std::vecto f_t obj = compute_objective(original_lp_, crushed_solution); mutex_original_lp_.unlock(); + if (solver_objective != nullptr) { *solver_objective = obj; } bool is_feasible = false; bool attempt_repair = false; bool success = false; @@ -687,10 +689,18 @@ void branch_and_bound_t::set_solution_from_submip( log_prefix, compute_user_objective(lp, obj)); - bool success = set_solution_from_heuristics(user_sol, heuristics_origin_t::SUBMIP); + // `obj` is in the sub-MIP's own space (fixed variables, own presolve offset), so it cannot be + // handed to solution_callback alongside a user-space assignment. + f_t original_lp_objective = std::numeric_limits::quiet_NaN(); + bool success = + set_solution_from_heuristics(user_sol, heuristics_origin_t::SUBMIP, &original_lp_objective); if (success) { submip_stats.save_success(fixrate); - if (settings_.solution_callback != nullptr) { settings_.solution_callback(user_sol, obj); } + cuopt_assert(std::isfinite(original_lp_objective), + "SubMIP incumbent objective must be finite when accepted"); + if (settings_.solution_callback != nullptr) { + settings_.solution_callback(user_sol, original_lp_objective); + } } } diff --git a/cpp/src/branch_and_bound/branch_and_bound.hpp b/cpp/src/branch_and_bound/branch_and_bound.hpp index 7cf5ed3680..5b95195bb5 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.hpp +++ b/cpp/src/branch_and_bound/branch_and_bound.hpp @@ -132,8 +132,12 @@ class branch_and_bound_t { } } - // Set a solution based on the user problem during the course of the solve - bool set_solution_from_heuristics(const std::vector& solution, heuristics_origin_t origin); + // Set a solution based on the user problem during the course of the solve. + // When non-null, `solver_objective` receives the objective of `solution` in original_lp_ space, + // which is the space every solution_callback consumer expects. + bool set_solution_from_heuristics(const std::vector& solution, + heuristics_origin_t origin, + f_t* solver_objective = nullptr); // Apply a solution found by a CPU FJ worker. void set_solution_from_cpu_fj(f_t obj, const std::vector& assignment, double work_units); diff --git a/cpp/src/mip_heuristics/diversity/population.cu b/cpp/src/mip_heuristics/diversity/population.cu index e6fffa97b2..033119915f 100644 --- a/cpp/src/mip_heuristics/diversity/population.cu +++ b/cpp/src/mip_heuristics/diversity/population.cu @@ -264,41 +264,6 @@ 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 (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(); - if (mip_solver_settings_accessor::has_semi_continuous_callback_translation( - context.settings)) { - mip::strip_semi_continuous_auxiliaries_from_assignment( - user_assignment_vec, - mip_solver_settings_accessor::get_semi_continuous_original_num_variables( - context.settings)); - } - 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) { @@ -309,15 +274,14 @@ void population_t::run_solution_callbacks(solution_t& sol) context.settings.benchmark_info_ptr->last_improvement_of_best_feasible = timer.elapsed_time(); } CUOPT_LOG_DEBUG("Population: Found new best solution %g", sol.get_user_objective()); - if (problem_ptr->branch_and_bound_callback != nullptr) { - problem_ptr->branch_and_bound_callback(sol.get_host_assignment(), - heuristics_origin_t::HEURISTICS); - } - for (auto callback : user_callbacks) { - if (callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { - auto get_sol_callback = static_cast(callback); - invoke_get_solution_callback(sol, get_sol_callback); + if (problem_ptr->branch_and_bound_callback != nullptr || + context.solution_publication.enabled()) { + auto host_assignment = sol.get_host_assignment(); + if (problem_ptr->branch_and_bound_callback != nullptr) { + problem_ptr->branch_and_bound_callback(host_assignment, heuristics_origin_t::HEURISTICS); } + context.solution_publication.publish_if_better( + problem_ptr, host_assignment, sol.get_objective()); } // Save the best objective here even if callback handling later exits early. // This prevents older solutions from being reported as "new best" in subsequent callbacks. diff --git a/cpp/src/mip_heuristics/diversity/population.cuh b/cpp/src/mip_heuristics/diversity/population.cuh index 593b1ddf1e..5a9db26928 100644 --- a/cpp/src/mip_heuristics/diversity/population.cuh +++ b/cpp/src/mip_heuristics/diversity/population.cuh @@ -160,9 +160,6 @@ 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_heuristics/problem/presolve_data.cu b/cpp/src/mip_heuristics/problem/presolve_data.cu index 3c621bc2cd..ae78c3778c 100644 --- a/cpp/src/mip_heuristics/problem/presolve_data.cu +++ b/cpp/src/mip_heuristics/problem/presolve_data.cu @@ -257,8 +257,8 @@ void presolve_data_t::set_papilo_presolve_data( } template -void presolve_data_t::papilo_uncrush_assignment( - problem_t& problem, rmm::device_uvector& assignment) const +void presolve_data_t::papilo_uncrush_assignment(rmm::device_uvector& assignment, + rmm::cuda_stream_view stream) const { if (papilo_presolve_ptr == nullptr) { CUOPT_LOG_INFO("Papilo presolve data not set, skipping uncrushing assignment"); @@ -266,15 +266,12 @@ void presolve_data_t::papilo_uncrush_assignment( } 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()); + auto h_assignment = cuopt::host_copy(assignment, 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(); + assignment.resize(full_assignment.size(), stream); + raft::copy(assignment.data(), full_assignment.data(), full_assignment.size(), stream); + stream.synchronize(); } #if MIP_INSTANTIATE_FLOAT || PDLP_INSTANTIATE_FLOAT diff --git a/cpp/src/mip_heuristics/problem/presolve_data.cuh b/cpp/src/mip_heuristics/problem/presolve_data.cuh index 713bb24c0d..65b492a7e6 100644 --- a/cpp/src/mip_heuristics/problem/presolve_data.cuh +++ b/cpp/src/mip_heuristics/problem/presolve_data.cuh @@ -122,8 +122,8 @@ class presolve_data_t { 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; + void papilo_uncrush_assignment(rmm::device_uvector& assignment, + rmm::cuda_stream_view stream) const; presolve_data_t(presolve_data_t&&) = default; presolve_data_t& operator=(presolve_data_t&&) = default; diff --git a/cpp/src/mip_heuristics/problem/problem.cu b/cpp/src/mip_heuristics/problem/problem.cu index 17d55b7cc2..b84206e08f 100644 --- a/cpp/src/mip_heuristics/problem/problem.cu +++ b/cpp/src/mip_heuristics/problem/problem.cu @@ -2192,9 +2192,10 @@ void problem_t::set_papilo_presolve_data( } template -void problem_t::papilo_uncrush_assignment(rmm::device_uvector& assignment) const +void problem_t::papilo_uncrush_assignment(rmm::device_uvector& assignment, + rmm::cuda_stream_view stream) const { - presolve_data.papilo_uncrush_assignment(const_cast(*this), assignment); + presolve_data.papilo_uncrush_assignment(assignment, stream); } template diff --git a/cpp/src/mip_heuristics/problem/problem.cuh b/cpp/src/mip_heuristics/problem/problem.cuh index 3ea3973d1d..fd38117b50 100644 --- a/cpp/src/mip_heuristics/problem/problem.cuh +++ b/cpp/src/mip_heuristics/problem/problem.cuh @@ -119,7 +119,12 @@ class problem_t { { return presolve_data.get_papilo_original_num_variables(); } - void papilo_uncrush_assignment(rmm::device_uvector& assignment) const; + void papilo_uncrush_assignment(rmm::device_uvector& assignment, + rmm::cuda_stream_view stream) const; + void papilo_uncrush_assignment(rmm::device_uvector& assignment) const + { + papilo_uncrush_assignment(assignment, handle_ptr->get_stream()); + } 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_heuristics/solution_publication.cuh b/cpp/src/mip_heuristics/solution_publication.cuh new file mode 100644 index 0000000000..0e5c1d92ea --- /dev/null +++ b/cpp/src/mip_heuristics/solution_publication.cuh @@ -0,0 +1,141 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +// Single point at which MIP incumbents are reported to the user get-solution callbacks. +// The heuristic thread (through the population) and the branch-and-bound thread both publish +// here, so the guard on the last published objective is shared and every incumbent is reported +// once, at the moment it is found rather than when the heuristic thread next drains its queue. +template +class solution_publication_t { + public: + solution_publication_t(const mip_solver_settings_t& settings, + const solver_stats_t& stats) + : settings_(settings), stats_(stats) + { + if (has_get_solution_callback()) { + RAFT_CUDA_TRY(cudaGetDevice(&device_id_)); + handle_ = std::make_unique(); + } + } + + // Whether any get-solution callback is registered. Callers can use this to skip assembling + // the host assignment that publish_if_better would otherwise discard. + bool enabled() const { return handle_ != nullptr; } + + // `assignment` and `solver_objective` are in problem_ptr's solver space, which is always + // oriented as a minimization. Returns whether the incumbent was published. + // + // Post-processing runs on a private stream, so this is safe to call from the branch-and-bound + // thread while the heuristic thread owns problem_ptr->handle_ptr's stream. + bool publish_if_better(problem_t* problem_ptr, + const std::vector& assignment, + f_t solver_objective) + { + if (handle_ == nullptr) { return false; } + cuopt_assert(problem_ptr != nullptr, "Publication problem pointer must not be null"); + cuopt_assert(std::isfinite(solver_objective), "Published objective must be finite"); + + std::lock_guard lock(mutex_); + if (!(solver_objective < best_published_objective_)) { return false; } + best_published_objective_ = solver_objective; + + const auto user_assignment = build_user_assignment(problem_ptr, assignment); + const f_t user_objective = problem_ptr->get_user_obj_from_solver_obj(solver_objective); + const f_t user_bound = stats_.get_solution_bound(); + CUOPT_LOG_DEBUG("Publishing incumbent: objective %g, %lu variables", + user_objective, + user_assignment.size()); + + for (auto callback : settings_.get_mip_callbacks()) { + if (callback == nullptr || + callback->get_type() != internals::base_solution_callback_type::GET_SOLUTION) { + continue; + } + // Each callback gets its own copies: the interface hands out mutable pointers. + std::vector callback_assignment(user_assignment); + std::vector callback_objective(1, user_objective); + std::vector callback_bound(1, user_bound); + auto get_sol_callback = static_cast(callback); + get_sol_callback->get_solution(callback_assignment.data(), + callback_objective.data(), + callback_bound.data(), + get_sol_callback->get_user_data()); + } + return true; + } + + private: + // Lifts a solver-space assignment into the space the callbacks were set up for. + std::vector build_user_assignment(problem_t* problem_ptr, + const std::vector& assignment) + { + // The B&B thread may never have selected a device of its own. + RAFT_CUDA_TRY(cudaSetDevice(device_id_)); + auto stream = handle_->get_stream(); + rmm::device_uvector d_assignment(assignment.size(), stream); + raft::copy(d_assignment.data(), assignment.data(), assignment.size(), stream); + // post_process_assignment writes through problem_ptr->presolve_data.fixed_var_assignment, + // which both publishing threads share: the caller's lock is what keeps them apart. + problem_ptr->post_process_assignment(d_assignment, true, stream); + if (problem_ptr->has_papilo_presolve_data()) { + problem_ptr->papilo_uncrush_assignment(d_assignment, stream); + } + auto user_assignment = cuopt::host_copy(d_assignment, stream); + if (mip_solver_settings_accessor::has_semi_continuous_callback_translation( + settings_)) { + strip_semi_continuous_auxiliaries_from_assignment( + user_assignment, + mip_solver_settings_accessor::get_semi_continuous_original_num_variables( + settings_)); + } + return user_assignment; + } + + bool has_get_solution_callback() const + { + for (auto callback : settings_.get_mip_callbacks()) { + if (callback != nullptr && + callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { + return true; + } + } + return false; + } + + const mip_solver_settings_t& settings_; + const solver_stats_t& stats_; + int device_id_{0}; + // Null when no get-solution callback is registered, which also disables publication. + std::unique_ptr handle_; + std::mutex mutex_; + f_t best_published_objective_{std::numeric_limits::max()}; +}; + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/solver.cu b/cpp/src/mip_heuristics/solver.cu index f8eac0c4d8..f0a5a3c5aa 100644 --- a/cpp/src/mip_heuristics/solver.cu +++ b/cpp/src/mip_heuristics/solver.cu @@ -69,6 +69,10 @@ struct branch_and_bound_solution_helper_t { void solution_callback(std::vector& solution, f_t objective) { + if (dm->context.settings.determinism_mode == CUOPT_MODE_OPPORTUNISTIC) { + dm->context.solution_publication.publish_if_better( + dm->context.problem_ptr, solution, objective); + } dm->population.add_external_solution(solution, objective, solution_origin_t::BRANCH_AND_BOUND); } @@ -197,12 +201,8 @@ solution_t mip_solver_t::run_solver() if (context.problem_ptr->empty) { CUOPT_LOG_INFO("Problem fully reduced in presolve"); 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.solution_publication.publish_if_better( + context.problem_ptr, sol.get_host_assignment(), sol.get_objective()); context.problem_ptr->post_process_solution(sol); return sol; } @@ -237,12 +237,8 @@ solution_t mip_solver_t::run_solver() if (run_presolve && context.problem_ptr->empty) { CUOPT_LOG_INFO("Problem full reduced in presolve"); 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.solution_publication.publish_if_better( + context.problem_ptr, sol.get_host_assignment(), sol.get_objective()); context.problem_ptr->post_process_solution(sol); return sol; } @@ -273,12 +269,8 @@ solution_t mip_solver_t::run_solver() 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.solution_publication.publish_if_better( + context.problem_ptr, sol.get_host_assignment(), sol.get_objective()); } context.problem_ptr->post_process_solution(sol); return sol; @@ -445,10 +437,10 @@ solution_t mip_solver_t::run_solver() branch_and_bound->set_concurrent_lp_root_solve(true); context.problem_ptr->branch_and_bound_callback = - std::bind(&mip::branch_and_bound_t::set_solution_from_heuristics, - branch_and_bound.get(), - std::placeholders::_1, - std::placeholders::_2); + [bb = branch_and_bound.get()](const std::vector& solution, + heuristics_origin_t origin) { + return bb->set_solution_from_heuristics(solution, origin); + }; } else if (context.settings.determinism_mode == CUOPT_MODE_DETERMINISTIC) { branch_and_bound->set_concurrent_lp_root_solve(false); // TODO once deterministic GPU heuristics are integrated diff --git a/cpp/src/mip_heuristics/solver_context.cuh b/cpp/src/mip_heuristics/solver_context.cuh index f98386cbaf..344d4e8d86 100644 --- a/cpp/src/mip_heuristics/solver_context.cuh +++ b/cpp/src/mip_heuristics/solver_context.cuh @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -58,6 +59,8 @@ struct mip_solver_context_t { std::atomic preempt_heuristic_solver_ = false; const mip_solver_settings_t settings; solver_stats_t stats; + // Every incumbent reported to the user goes through here, from whichever thread found it. + solution_publication_t solution_publication{settings, stats}; // Work limit context for tracking work units in deterministic mode (shared across all timers in // GPU heuristic loop) work_limit_context_t gpu_heur_loop{"GPUHeur"}; From 8b26bcdf9401b10f95d7e5c4340f7b7d7a300d3c Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 09:19:33 -0700 Subject: [PATCH 45/78] publish cpufj scratch solutions early as well --- cpp/src/mip_heuristics/local_search/local_search.cu | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cpp/src/mip_heuristics/local_search/local_search.cu b/cpp/src/mip_heuristics/local_search/local_search.cu index 23edf555cd..c1d80fcda7 100644 --- a/cpp/src/mip_heuristics/local_search/local_search.cu +++ b/cpp/src/mip_heuristics/local_search/local_search.cu @@ -79,6 +79,7 @@ void local_search_t::start_cpufj_scratch_threads(population_timprovement_callback = [this, &population, problem_ptr = context.problem_ptr]( f_t obj, const std::vector& h_vec, double /*work_units*/) { + context.solution_publication.publish_if_better(problem_ptr, h_vec, obj); population.add_external_solution(h_vec, obj, solution_origin_t::CPUFJ); (void)problem_ptr; if (obj < this->local_search_best_obj) { @@ -127,6 +128,7 @@ void local_search_t::start_cpufj_lptopt_scratch_threads( scratch_cpu_fj_on_lp_opt->log_prefix = "******* scratch on LP optimal: "; scratch_cpu_fj_on_lp_opt->improvement_callback = [this, &population](f_t obj, const std::vector& h_vec, double /*work_units*/) { + context.solution_publication.publish_if_better(context.problem_ptr, h_vec, obj); population.add_external_solution(h_vec, obj, solution_origin_t::CPUFJ); if (obj < this->local_search_best_obj) { CUOPT_LOG_DEBUG("******* New local search best obj %g, best overall %g", From abdad3e265f4ee338d5bcf369c86c9ad3e09548d Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 10:11:23 -0700 Subject: [PATCH 46/78] absorb cuda driver startup in run_mip.cpp --- benchmarks/linear_programming/cuopt/run_mip.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/benchmarks/linear_programming/cuopt/run_mip.cpp b/benchmarks/linear_programming/cuopt/run_mip.cpp index 207c553dcb..131ec15e6d 100644 --- a/benchmarks/linear_programming/cuopt/run_mip.cpp +++ b/benchmarks/linear_programming/cuopt/run_mip.cpp @@ -407,6 +407,8 @@ void return_gpu_to_the_queue(std::unordered_map& pid_gpu_map, int main(int argc, char* argv[]) { + (void)cudaFree(0); + argparse::ArgumentParser program("solve_MIP"); // Define all arguments with appropriate defaults and help messages From 04a25faca75eba230d722328ed121ffa932a4f2e Mon Sep 17 00:00:00 2001 From: yboucher Date: Fri, 21 Aug 2026 02:53:29 -0700 Subject: [PATCH 47/78] fix multigpu runs --- benchmarks/linear_programming/cuopt/run_mip.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmarks/linear_programming/cuopt/run_mip.cpp b/benchmarks/linear_programming/cuopt/run_mip.cpp index 131ec15e6d..6a9a3303fd 100644 --- a/benchmarks/linear_programming/cuopt/run_mip.cpp +++ b/benchmarks/linear_programming/cuopt/run_mip.cpp @@ -198,6 +198,8 @@ int run_single_file(std::string file_path, double work_limit, bool deterministic) { + (void)cudaFree(0); + const raft::handle_t handle_{}; cuopt::mathematical_optimization::mip_solver_settings_t settings; std::string base_filename = file_path.substr(file_path.find_last_of("/\\") + 1); @@ -407,8 +409,6 @@ void return_gpu_to_the_queue(std::unordered_map& pid_gpu_map, int main(int argc, char* argv[]) { - (void)cudaFree(0); - argparse::ArgumentParser program("solve_MIP"); // Define all arguments with appropriate defaults and help messages From a977cf9cdc6d9b4511141ae5fa496fe84fbfca6d Mon Sep 17 00:00:00 2001 From: yboucher Date: Fri, 21 Aug 2026 05:28:14 -0700 Subject: [PATCH 48/78] lhs refresh period stretch, lazy mtm cache invalidate, magnitude aware objective term --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 88 +++++++++++++------ .../feasibility_jump/fj_cpu.cuh | 11 +++ .../feasibility_jump/fj_cpu_binary.cu | 23 ++++- 3 files changed, 92 insertions(+), 30 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 5c48cab1e3..7f07df475a 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -38,7 +38,7 @@ #include #include -#define CPUFJ_TIMING_TRACE 0 +#define CPUFJ_TIMING_TRACE 1 // Define CPUFJ_NVTX_RANGES to enable detailed NVTX profiling ranges #ifdef CPUFJ_NVTX_RANGES @@ -639,10 +639,18 @@ static inline std::pair compute_score(fj_cpu_climber_t 0) - base_obj = -fj_cpu.h_objective_weight; + if (fj_cpu.h_objective_weight > 0 && obj_diff != 0) { + // Scaling base by the objective magnitude only means something where there is feasibility + // impact to trade against; at base_feas_sum zero it would only make base distinct across + // candidates, which strands the bonus stage of the staged comparison. + f_t weighted = fj_cpu.h_objective_weight; + if (base_feas_sum != 0) { + cuopt_assert(fj_cpu.obj_magnitude > 0, "objective magnitude unit must be positive"); + weighted *= min((f_t)fj_obj_mult_max, + max((f_t)fj_obj_mult_min, fabs(obj_diff) / fj_cpu.obj_magnitude)); + } + base_obj = obj_diff < 0 ? weighted : -weighted; + } f_t bonus_breakthrough = 0; @@ -939,7 +947,8 @@ static void smooth_weights(fj_cpu_climber_t& fj_cpu) } if (fj_cpu.h_objective_weight > 0 && fj_cpu.h_incumbent_objective >= fj_cpu.h_best_objective) { - fj_cpu.h_objective_weight = max((f_t)0, fj_cpu.h_objective_weight - 1); + fj_cpu.h_objective_weight = + max(fj_cpu.seed_objective_weight, fj_cpu.h_objective_weight - 1); } } @@ -989,11 +998,7 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) } // Invalidate related cached move scores - auto [relvar_offset_begin, relvar_offset_end] = - range_for_constraint(fj_cpu, cstr_idx); - for (auto i = relvar_offset_begin; i < relvar_offset_end; i++) { - fj_cpu.cached_mtm_moves[i].first = 0; - } + fj_cpu.h_cstr_version[cstr_idx]++; } if (fj_cpu.violated_constraints.empty()) { fj_cpu.h_objective_weight += 1; } @@ -1072,11 +1077,7 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, cuopt_assert(isfinite(fj_cpu.h_lhs[cstr_idx]), "assignment should be finite"); // Invalidate related cached move scores - auto [relvar_offset_begin, relvar_offset_end] = - range_for_constraint(fj_cpu, cstr_idx); - for (auto i = relvar_offset_begin; i < relvar_offset_end; i++) { - fj_cpu.cached_mtm_moves[i].first = 0; - } + fj_cpu.h_cstr_version[cstr_idx]++; } if (previous_viol > 0 && fj_cpu.violated_constraints.empty()) { @@ -1171,7 +1172,11 @@ static thrust::tuple find_mtm_move( auto [offset_begin, offset_end] = range_for_constraint(fj_cpu, cstr_idx); for (auto i = offset_begin; i < offset_end; i++) { // early cached check - if (auto& cached_move = fj_cpu.cached_mtm_moves[i]; cached_move.first != 0) { + cuopt_assert(fj_cpu.cached_mtm_moves_version[i] <= fj_cpu.h_cstr_version[cstr_idx], + "cached move newer than its constraint"); + if (auto& cached_move = fj_cpu.cached_mtm_moves[i]; + cached_move.first != 0 && + fj_cpu.cached_mtm_moves_version[i] == fj_cpu.h_cstr_version[cstr_idx]) { if (best_score < cached_move.second) { auto var_idx = fj_cpu.h_variables[i]; if (check_variable_within_bounds( @@ -1241,8 +1246,9 @@ static thrust::tuple find_mtm_move( cuopt_assert(move.var_idx < fj_cpu.h_assignment.size(), "move.var_idx is out of bounds"); cuopt_assert(move.var_idx >= 0, "move.var_idx is not positive"); - auto [score, infeasibility] = compute_score(fj_cpu, var_idx, delta); - fj_cpu.cached_mtm_moves[i] = std::make_pair(delta, score); + auto [score, infeasibility] = compute_score(fj_cpu, var_idx, delta); + fj_cpu.cached_mtm_moves[i] = std::make_pair(delta, score); + fj_cpu.cached_mtm_moves_version[i] = fj_cpu.h_cstr_version[cstr_idx]; fj_cpu.miss_count++; // reject this move if it would increase the target variable to a numerically unstable value if (fj_cpu.view.move_numerically_stable( @@ -1389,6 +1395,7 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) fj_cpu.h_assignment.begin(), fj_cpu.h_assignment.end(), fj_cpu.h_obj_coeffs.begin(), 0.); } + template static thrust::tuple find_lift_move( fj_cpu_climber_t& fj_cpu) @@ -1563,6 +1570,13 @@ static void reset_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) fj_cpu.iters_since_infeasible_improve = 0; } +template +static void invalidate_mtm_cache(fj_cpu_climber_t& fj_cpu) +{ + for (size_t c = 0; c < fj_cpu.h_cstr_version.size(); ++c) + fj_cpu.h_cstr_version[c]++; +} + template static void restart_from_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) { @@ -1570,8 +1584,7 @@ static void restart_from_infeasible_checkpoint(fj_cpu_climber_t& fj_cp "incumbent_assignment span would be invalidated"); fj_cpu.h_assignment = fj_cpu.h_best_infeasible_assignment; recompute_lhs(fj_cpu); - for (size_t i = 0; i < fj_cpu.cached_mtm_moves.size(); ++i) - fj_cpu.cached_mtm_moves[i].first = 0; + invalidate_mtm_cache(fj_cpu); } template @@ -1824,6 +1837,8 @@ static void wire_fj_cpu_host_views( // nnz count fj_cpu.cached_mtm_moves.resize(fj_cpu.h_coefficients.size(), std::make_pair(0, fj_staged_score_t::zero())); + fj_cpu.cached_mtm_moves_version.assign(fj_cpu.h_coefficients.size(), -1); + fj_cpu.h_cstr_version.assign(n_constraints, 0); fj_cpu.flip_move_computed.resize(n_variables, false); fj_cpu.var_bitmap.resize(n_variables, false); @@ -1853,6 +1868,15 @@ void finalize_fj_cpu_host_initialization( fj_cpu.view.objective_vars = raft::device_span(fj_cpu.h_objective_vars.data(), fj_cpu.h_objective_vars.size()); + f_t abs_obj_sum = 0; + for (auto var_idx : fj_cpu.h_objective_vars) { + const f_t coeff = fj_cpu.h_obj_coeffs[var_idx]; + abs_obj_sum += coeff < 0 ? -coeff : coeff; + } + fj_cpu.obj_magnitude = abs_obj_sum > 0 ? abs_obj_sum / fj_cpu.h_objective_vars.size() : f_t{1}; + cuopt_assert(isfinite(fj_cpu.obj_magnitude) && fj_cpu.obj_magnitude > 0, + "objective magnitude unit must be finite and positive"); + fj_cpu.cached_cstr_bounds.resize(fj_cpu.h_reverse_coefficients.size()); for (i_t var_idx = 0; var_idx < n_variables; ++var_idx) { auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); @@ -1912,6 +1936,7 @@ static void finalize_fj_cpu_host_initialization_from_template( fj_cpu.h_objective_vars = tmpl.h_objective_vars; fj_cpu.cached_cstr_bounds = tmpl.cached_cstr_bounds; + fj_cpu.obj_magnitude = tmpl.obj_magnitude; fj_cpu.h_lhs = tmpl.h_lhs; fj_cpu.h_lhs_sumcomp = tmpl.h_lhs_sumcomp; @@ -2092,6 +2117,8 @@ static void sanity_checks(fj_cpu_climber_t& fj_cpu) cuopt_assert(fj_cpu.h_cstr_right_weights[cstr_idx] >= 0, "Weights should be positive or zero"); } cuopt_assert(fj_cpu.h_objective_weight >= 0, "Objective weight should be positive or zero"); + cuopt_assert(fj_cpu.seed_objective_weight >= 0, + "Objective weight floor should be positive or zero"); } template @@ -2123,6 +2150,9 @@ std::unique_ptr> fj_t::create_cpu_climber( return fj_cpu; // move } +constexpr int32_t fj_nnz_per_refresh_stretch = 100000; +constexpr int32_t fj_max_refresh_stretch = 8; + template void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double work_unit_limit) { @@ -2146,6 +2176,14 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w fj_cpu->restores_since_improvement = 0; fj_cpu->max_restores_since_improvement = 0; + // The recompute is O(nnz), so a fixed period costs a growing share of the budget. + cuopt_assert(fj_cpu->settings.parameters.lhs_refresh_period > 0, + "lhs_refresh_period should be positive"); + const i_t nnz_stretch = std::min( + (i_t)fj_cpu->h_coefficients.size() / fj_nnz_per_refresh_stretch, fj_max_refresh_stretch); + const i_t refresh_period = fj_cpu->settings.parameters.lhs_refresh_period * (1 + nnz_stretch); + cuopt_assert(refresh_period > 0, "refresh period overflowed"); + while (!fj_cpu->halted && !fj_cpu->preemption_flag.load()) { // Check if 5 seconds have passed auto now = std::chrono::high_resolution_clock::now(); @@ -2167,10 +2205,7 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w // periodically recompute the LHS and violation scores // to correct any accumulated numerical errors - cuopt_assert(fj_cpu->settings.parameters.lhs_refresh_period > 0, - "lhs_refresh_period should be positive"); - if (fj_cpu->iterations % fj_cpu->settings.parameters.lhs_refresh_period == 0 || - fj_cpu->trigger_early_lhs_recomputation) { + if (fj_cpu->iterations % refresh_period == 0 || fj_cpu->trigger_early_lhs_recomputation) { recompute_lhs(*fj_cpu); fj_cpu->trigger_early_lhs_recomputation = false; } @@ -2217,8 +2252,7 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w track_infeasible_checkpoint(*fj_cpu); if (should_perturb) { perturb(*fj_cpu); - for (size_t i = 0; i < fj_cpu->cached_mtm_moves.size(); i++) - fj_cpu->cached_mtm_moves[i].first = 0; + invalidate_mtm_cache(*fj_cpu); } two_opt_move_t two_opt_move; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 53de8ba2d4..6b4d0f0f76 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -85,6 +85,9 @@ struct host_contiguous_set_t { std::vector is_member; }; +constexpr double fj_obj_mult_min = 0.25; +constexpr double fj_obj_mult_max = 4.0; + // NOTE: this seems an easy pick for reflection/xmacros once this is available (C++26?) // Maintaining a single source of truth for all members would be nice template @@ -180,6 +183,10 @@ struct fj_cpu_climber_t { ins_vector h_assignment; ins_vector h_best_assignment; f_t h_objective_weight; + // Lower bound h_objective_weight decays to, so a lane seeded with objective pressure keeps it. + f_t seed_objective_weight{0}; + // Mean absolute nonzero objective coefficient; the unit of the objective score term. + f_t obj_magnitude{1}; f_t h_incumbent_objective; f_t h_best_objective; i_t last_feasible_entrance_iter{0}; @@ -210,6 +217,10 @@ struct fj_cpu_climber_t { // CSR nnz offset -> (delta, score) std::vector> cached_mtm_moves; + // Entry i is live only while cached_mtm_moves_version[i] == h_cstr_version of i's row. + std::vector cached_mtm_moves_version; + std::vector h_cstr_version; + // CSC (transposed!) nnz-offset-indexed constraint bounds (lb, ub) // std::pair better compile down to 16 bytes!! GCC do your job! ins_vector> cached_cstr_bounds; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 536254bb31..7ba19e6a52 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -205,7 +205,6 @@ constexpr int32_t fj_bin_restart_period = 5000000; // Escalation threshold and step, in infeasible local minima without a severity improvement. constexpr int32_t fj_bin_ddfw_escalate_after = 2000; constexpr int32_t fj_bin_ddfw_escalate_max = 100; - // prefetch distance // TODO: check if it actually matters at all for performance constexpr int32_t fj_bin_pf_dist = 8; @@ -589,6 +588,8 @@ struct fj_bin_engine_t { int32_t objective_weight{0}; int32_t seed_objective_weight{0}; + // Mean absolute nonzero objective coefficient; the unit of the objective score term. + double obj_magnitude{1.0}; double incumbent_objective{0}; double best_objective{std::numeric_limits::infinity()}; int32_t max_weight{1}; @@ -768,8 +769,18 @@ struct fj_bin_engine_t { int64_t objective_terms(int32_t v, int8_t delta) const { const double obj_diff = pb.objective[v] * delta; - const int32_t base = obj_diff < 0 ? objective_weight : (obj_diff > 0 ? -objective_weight : 0); - int32_t bonus = 0; + int32_t base = 0; + if (obj_diff != 0) { + cuopt_assert(obj_magnitude > 0, "objective magnitude unit must be positive"); + const double rel = std::fabs(obj_diff) / obj_magnitude; + const double mult = + rel < fj_obj_mult_min ? fj_obj_mult_min : (rel > fj_obj_mult_max ? fj_obj_mult_max : rel); + const double raw = objective_weight * mult; + cuopt_assert(fj_bin_in_int32(raw), "scaled objective weight out of int32 range"); + const int32_t scaled = (int32_t)std::lround(raw); + base = obj_diff < 0 ? scaled : -scaled; + } + int32_t bonus = 0; const bool old_better = incumbent_objective < best_objective; const bool new_better = incumbent_objective + obj_diff < best_objective; if (!old_better && new_better) { @@ -1253,6 +1264,12 @@ struct fj_bin_engine_t { const int32_t seeded_weight = (int32_t)std::lround(climber.h_objective_weight); cuopt_assert(seeded_weight >= 0, "objective weight should be positive or zero"); + double abs_obj_sum = 0; + for (int32_t v : pb.objective_vars) abs_obj_sum += std::fabs(pb.objective[v]); + obj_magnitude = abs_obj_sum > 0 ? abs_obj_sum / (double)pb.objective_vars.size() : 1.0; + cuopt_assert(std::isfinite(obj_magnitude) && obj_magnitude > 0, + "objective magnitude unit must be finite and positive"); + argmax_tile = fj_bin_argmax_tile(); objective_weight = seeded_weight > 0 ? seeded_weight : 0; seed_objective_weight = objective_weight; From b4ed10494500655059e565d16e896e10e01e88de Mon Sep 17 00:00:00 2001 From: yboucher Date: Fri, 21 Aug 2026 06:36:53 -0700 Subject: [PATCH 49/78] objective weight tweaks --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 26 +++++++++- .../feasibility_jump/fj_cpu_binary.cu | 48 +++++++++++++++---- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 7f07df475a..4b36bb3aaa 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -38,7 +38,7 @@ #include #include -#define CPUFJ_TIMING_TRACE 1 +#define CPUFJ_TIMING_TRACE 0 // Define CPUFJ_NVTX_RANGES to enable detailed NVTX profiling ranges #ifdef CPUFJ_NVTX_RANGES @@ -1004,6 +1004,12 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) if (fj_cpu.violated_constraints.empty()) { fj_cpu.h_objective_weight += 1; } } +// Applied to the objective weight when a new incumbent lands: the bump it gains, and the ceiling +// it is held at so smooth_weights and update_weights cannot walk it out of scale with the +// feasibility term. +constexpr double fj_obj_weight_incumbent_bump = 4.0; +constexpr double fj_obj_weight_incumbent_cap = 64.0; + template static void apply_move(fj_cpu_climber_t& fj_cpu, i_t var_idx, @@ -1109,6 +1115,13 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, fj_cpu.h_incumbent_objective, fj_cpu.h_assignment, current_work_units); } fj_cpu.feasible_found = true; + // Counteract the smooth_weights decay for a lane that is actively improving, and hold the + // weight at a scale where base_feas_sum still registers against it. + if (fj_cpu.h_objective_weight > 0) { + fj_cpu.h_objective_weight = + min((f_t)fj_obj_weight_incumbent_cap, + fj_cpu.h_objective_weight + (f_t)fj_obj_weight_incumbent_bump); + } } } @@ -2704,6 +2717,14 @@ void build_climber_portfolio(problem_t& problem, int64_t base_seed) { const int n_climbers = static_cast(climbers.size()); + + // Objective pressure across the portfolio, indexed by lane. Lanes 0 and 3 stay pure feasibility + // seekers until they cross, since the objective term only enters the score once the weight is + // positive; their nonzero floor then keeps a pull on the objective afterwards rather than letting + // smooth_weights decay it back to nothing. + const f_t obj_weight_ladder[4] = {0, 4, 32, 0}; + const f_t obj_weight_floor[4] = {1, 4, 32, 1}; + for (int k = 0; k < n_climbers; ++k) { preemption_flags[k].store(false); fj_settings_t settings; @@ -2723,6 +2744,9 @@ void build_climber_portfolio(problem_t& problem, climbers[k]->mtm_sat_samples = std::uniform_int_distribution(10, 30)(rng); climbers[k]->nnz_samples = std::uniform_int_distribution(2000, 15000)(rng); climbers[k]->perturb_interval = std::uniform_int_distribution(50, 500)(rng); + + climbers[k]->h_objective_weight = obj_weight_ladder[k % 4]; + //climbers[k]->seed_objective_weight = obj_weight_floor[k % 4]; } } diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 7ba19e6a52..65ccbf1d4a 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -205,6 +205,10 @@ constexpr int32_t fj_bin_restart_period = 5000000; // Escalation threshold and step, in infeasible local minima without a severity improvement. constexpr int32_t fj_bin_ddfw_escalate_after = 2000; constexpr int32_t fj_bin_ddfw_escalate_max = 100; + +// The same, in feasible local minima without a best-objective improvement. +constexpr int32_t fj_bin_obj_stall_after = 50; +constexpr int32_t fj_bin_obj_escalate_max = 10; // prefetch distance // TODO: check if it actually matters at all for performance constexpr int32_t fj_bin_pf_dist = 8; @@ -588,6 +592,9 @@ struct fj_bin_engine_t { int32_t objective_weight{0}; int32_t seed_objective_weight{0}; + // Feasible local minima since best_objective last moved, and the value it was last seen at. + int32_t iterations_at_same_objective{0}; + double last_best_objective{std::numeric_limits::infinity()}; // Mean absolute nonzero objective coefficient; the unit of the objective score term. double obj_magnitude{1.0}; double incumbent_objective{0}; @@ -993,10 +1000,29 @@ struct fj_bin_engine_t { reweight_constraint(best_donor, donated); } } - if (violated_list.empty()) objective_weight += 1; + if (violated_list.empty()) { + if (best_objective < last_best_objective) { + iterations_at_same_objective = 0; + last_best_objective = best_objective; + } else { + ++iterations_at_same_objective; + } + objective_weight += objective_weight_increment(); + } track_infeasible_checkpoint(); } + // Stall-escalation for the objective weight, the feasible-region counterpart of ddfw_transfer: + // a lane that keeps reaching local minima without moving its best objective needs more + // objective pressure than one that is still improving. + int32_t objective_weight_increment() const + { + if (iterations_at_same_objective <= fj_bin_obj_stall_after) return 1; + const int32_t steps = + 1 + (iterations_at_same_objective - fj_bin_obj_stall_after) / fj_bin_obj_stall_after; + return steps < fj_bin_obj_escalate_max ? steps : fj_bin_obj_escalate_max; + } + void reset_infeasible_checkpoint() { best_infeasible_assign.clear(); @@ -1270,15 +1296,17 @@ struct fj_bin_engine_t { cuopt_assert(std::isfinite(obj_magnitude) && obj_magnitude > 0, "objective magnitude unit must be finite and positive"); - argmax_tile = fj_bin_argmax_tile(); - objective_weight = seeded_weight > 0 ? seeded_weight : 0; - seed_objective_weight = objective_weight; - max_weight = fj_bin_ddfw_init; - incumbent_objective = 0; - best_objective = std::numeric_limits::infinity(); - feasible_found = false; - iters = 0; - last_restart_iter = 0; + argmax_tile = fj_bin_argmax_tile(); + objective_weight = seeded_weight > 0 ? seeded_weight : 0; + seed_objective_weight = objective_weight; + max_weight = fj_bin_ddfw_init; + incumbent_objective = 0; + best_objective = std::numeric_limits::infinity(); + last_best_objective = std::numeric_limits::infinity(); + iterations_at_same_objective = 0; + feasible_found = false; + iters = 0; + last_restart_iter = 0; recompute_slack(); } From 64b5bfde5b2b8713113ad35130758b4cf58d6c50 Mon Sep 17 00:00:00 2001 From: yboucher Date: Fri, 21 Aug 2026 07:41:10 -0700 Subject: [PATCH 50/78] perf optimization --- .../feasibility_jump/feasibility_jump.cuh | 9 ++-- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 43 ++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh index ac1da031e3..437dfa3a1b 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh @@ -529,12 +529,14 @@ class fj_t { HDI f_t lower_excess_score(i_t cstr, f_t lhs, f_t c_lb) const { - return raft::min(lhs - c_lb, (f_t)0); + const f_t excess = lhs - c_lb; + return excess < (f_t)0 ? excess : (f_t)0; } HDI f_t upper_excess_score(i_t cstr, f_t lhs, f_t c_ub) const { - return raft::min(c_ub - lhs, (f_t)0); + const f_t excess = c_ub - lhs; + return excess < (f_t)0 ? excess : (f_t)0; } // Computes the constraint's contribution to the feasibility score: @@ -564,7 +566,8 @@ class fj_t { { f_t cstr_tolerance = get_cstr_tolerance( c_lb, c_ub, pb.tolerances.absolute_tolerance, pb.tolerances.relative_tolerance); - return max((f_t)1e-12, cstr_tolerance - MACHINE_EPSILON); + const f_t corrected = cstr_tolerance - MACHINE_EPSILON; + return corrected > (f_t)1e-12 ? corrected : (f_t)1e-12; } HDI f_t get_corrected_tolerance(i_t cstr) const { diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 4b36bb3aaa..ac1a9aafba 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -38,7 +38,7 @@ #include #include -#define CPUFJ_TIMING_TRACE 0 +#define CPUFJ_TIMING_TRACE 1 // Define CPUFJ_NVTX_RANGES to enable detailed NVTX profiling ranges #ifdef CPUFJ_NVTX_RANGES @@ -163,6 +163,13 @@ std::pair feas_score_constraint(const typename fj_t::climber f_t bounds[2] = {c_lb, c_ub}; cuopt_assert(isfinite(c_lb) || isfinite(c_ub), "no range"); + + // Independent of bound_idx, so paid once rather than on both passes of an equality row. + const f_t moved_lhs = current_lhs + cstr_coeff * delta; + const f_t cstr_tolerance = fj.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + const bool old_viol = fj.excess_score(cstr_idx, current_lhs, c_lb, c_ub) < -cstr_tolerance; + const bool new_viol = fj.excess_score(cstr_idx, moved_lhs, c_lb, c_ub) < -cstr_tolerance; + for (i_t bound_idx = 0; bound_idx < 2; ++bound_idx) { if (!isfinite(bounds[bound_idx])) continue; @@ -177,7 +184,7 @@ std::pair feas_score_constraint(const typename fj_t::climber f_t sign = bound_idx == 0 ? -1 : 1; f_t rhs = bounds[bound_idx] * sign; f_t old_lhs = current_lhs * sign; - f_t new_lhs = (current_lhs + cstr_coeff * delta) * sign; + f_t new_lhs = moved_lhs * sign; f_t old_slack = rhs - old_lhs; f_t new_slack = rhs - new_lhs; @@ -187,12 +194,6 @@ std::pair feas_score_constraint(const typename fj_t::climber cuopt_assert(isfinite(new_lhs), ""); cuopt_assert(isfinite(old_slack) && isfinite(new_slack), ""); - f_t cstr_tolerance = fj.get_corrected_tolerance(cstr_idx, c_lb, c_ub); - - bool old_viol = fj.excess_score(cstr_idx, current_lhs, c_lb, c_ub) < -cstr_tolerance; - bool new_viol = - fj.excess_score(cstr_idx, current_lhs + cstr_coeff * delta, c_lb, c_ub) < -cstr_tolerance; - bool old_sat = old_lhs < rhs + cstr_tolerance; bool new_sat = new_lhs < rhs + cstr_tolerance; @@ -288,43 +289,43 @@ static void print_timing_stats(fj_cpu_climber_t& fj_cpu) auto [apply_avg, apply_total] = compute_avg_and_total(fj_cpu.apply_move_times); auto [weights_avg, weights_total] = compute_avg_and_total(fj_cpu.update_weights_times); auto [compute_score_avg, compute_score_total] = compute_avg_and_total(fj_cpu.compute_score_times); - CUOPT_LOG_TRACE("=== Timing Statistics (Iteration %d) ===", fj_cpu.iterations); - CUOPT_LOG_TRACE("find_lift_move: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("=== Timing Statistics (Iteration %d) ===", fj_cpu.iterations); + CUOPT_LOG_DEBUG("find_lift_move: avg=%.6f ms, total=%.6f ms, calls=%zu", lift_avg * 1000.0, lift_total * 1000.0, fj_cpu.find_lift_move_times.size()); - CUOPT_LOG_TRACE("find_mtm_move_viol: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("find_mtm_move_viol: avg=%.6f ms, total=%.6f ms, calls=%zu", viol_avg * 1000.0, viol_total * 1000.0, fj_cpu.find_mtm_move_viol_times.size()); - CUOPT_LOG_TRACE("find_mtm_move_sat: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("find_mtm_move_sat: avg=%.6f ms, total=%.6f ms, calls=%zu", sat_avg * 1000.0, sat_total * 1000.0, fj_cpu.find_mtm_move_sat_times.size()); - CUOPT_LOG_TRACE("apply_move: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("apply_move: avg=%.6f ms, total=%.6f ms, calls=%zu", apply_avg * 1000.0, apply_total * 1000.0, fj_cpu.apply_move_times.size()); - CUOPT_LOG_TRACE("update_weights: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("update_weights: avg=%.6f ms, total=%.6f ms, calls=%zu", weights_avg * 1000.0, weights_total * 1000.0, fj_cpu.update_weights_times.size()); - CUOPT_LOG_TRACE("compute_score: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("compute_score: avg=%.6f ms, total=%.6f ms, calls=%zu", compute_score_avg * 1000.0, compute_score_total * 1000.0, fj_cpu.compute_score_times.size()); - CUOPT_LOG_TRACE("cache hit percentage: %.2f%%", + CUOPT_LOG_DEBUG("cache hit percentage: %.2f%%", (double)fj_cpu.hit_count / (fj_cpu.hit_count + fj_cpu.miss_count) * 100.0); - CUOPT_LOG_TRACE("bin candidate move hit percentage: %.2f%%", + CUOPT_LOG_DEBUG("bin candidate move hit percentage: %.2f%%", (double)fj_cpu.candidate_move_hits[0] / (fj_cpu.candidate_move_hits[0] + fj_cpu.candidate_move_misses[0]) * 100.0); - CUOPT_LOG_TRACE("int candidate move hit percentage: %.2f%%", + CUOPT_LOG_DEBUG("int candidate move hit percentage: %.2f%%", (double)fj_cpu.candidate_move_hits[1] / (fj_cpu.candidate_move_hits[1] + fj_cpu.candidate_move_misses[1]) * 100.0); - CUOPT_LOG_TRACE("cont candidate move hit percentage: %.2f%%", + CUOPT_LOG_DEBUG("cont candidate move hit percentage: %.2f%%", (double)fj_cpu.candidate_move_hits[2] / (fj_cpu.candidate_move_hits[2] + fj_cpu.candidate_move_misses[2]) * 100.0); - CUOPT_LOG_TRACE("========================================"); + CUOPT_LOG_DEBUG("========================================"); } template @@ -2357,7 +2358,7 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w #if CPUFJ_TIMING_TRACE // Print final timing statistics - CUOPT_LOG_TRACE("=== Final Timing Statistics ==="); + CUOPT_LOG_DEBUG("=== Final Timing Statistics ==="); print_timing_stats(*fj_cpu); #endif } From 9c9488989d4a595ea2b854fa0ff9a6bbcd722204 Mon Sep 17 00:00:00 2001 From: yboucher Date: Fri, 21 Aug 2026 08:14:57 -0700 Subject: [PATCH 51/78] more optimization --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 44 ++++++++++++------- .../cuopt-developer/references/conventions.md | 18 ++++++++ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index ac1a9aafba..d05c721c00 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -38,7 +38,7 @@ #include #include -#define CPUFJ_TIMING_TRACE 1 +#define CPUFJ_TIMING_TRACE 0 // Define CPUFJ_NVTX_RANGES to enable detailed NVTX profiling ranges #ifdef CPUFJ_NVTX_RANGES @@ -164,7 +164,7 @@ std::pair feas_score_constraint(const typename fj_t::climber f_t bounds[2] = {c_lb, c_ub}; cuopt_assert(isfinite(c_lb) || isfinite(c_ub), "no range"); - // Independent of bound_idx, so paid once rather than on both passes of an equality row. + // Independent of bound_idx. const f_t moved_lhs = current_lhs + cstr_coeff * delta; const f_t cstr_tolerance = fj.get_corrected_tolerance(cstr_idx, c_lb, c_ub); const bool old_viol = fj.excess_score(cstr_idx, current_lhs, c_lb, c_ub) < -cstr_tolerance; @@ -617,23 +617,37 @@ static inline std::pair compute_score(fj_cpu_climber_t(fj_cpu, var_idx); fj_cpu.nnz_processed_window += (offset_end - offset_begin); + const size_t nnz_read = (size_t)(offset_end - offset_begin); + fj_cpu.h_reverse_constraints.byte_loads += nnz_read * sizeof(i_t); + fj_cpu.h_reverse_coefficients.byte_loads += nnz_read * sizeof(f_t); + fj_cpu.cached_cstr_bounds.byte_loads += nnz_read * sizeof(std::pair); + fj_cpu.h_lhs.byte_loads += nnz_read * sizeof(f_t); + fj_cpu.h_cstr_left_weights.byte_loads += nnz_read * sizeof(f_t); + fj_cpu.h_cstr_right_weights.byte_loads += nnz_read * sizeof(f_t); + + const i_t* const rev_cstr = fj_cpu.view.pb.reverse_constraints.data(); + const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); + const f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); + const f_t* const weight_l = fj_cpu.view.cstr_left_weights.data(); + const f_t* const weight_r = fj_cpu.view.cstr_right_weights.data(); + const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); + for (i_t i = offset_begin; i < offset_end; i++) { - auto cstr_idx = fj_cpu.h_reverse_constraints[i]; - auto cstr_coeff = fj_cpu.h_reverse_coefficients[i]; - auto [c_lb, c_ub] = fj_cpu.cached_cstr_bounds[i].get(); + const i_t cstr_idx = rev_cstr[i]; + const f_t cstr_coeff = rev_coeff[i]; + const auto [c_lb, c_ub] = cstr_bounds[i]; cuopt_assert(c_lb <= c_ub, "invalid bounds"); - auto [cstr_base_feas, cstr_bonus_robust] = - feas_score_constraint(fj_cpu.view, - delta, - cstr_idx, - cstr_coeff, - c_lb, - c_ub, - fj_cpu.h_lhs[cstr_idx], - fj_cpu.h_cstr_left_weights[cstr_idx], - fj_cpu.h_cstr_right_weights[cstr_idx]); + auto [cstr_base_feas, cstr_bonus_robust] = feas_score_constraint(fj_cpu.view, + delta, + cstr_idx, + cstr_coeff, + c_lb, + c_ub, + row_lhs[cstr_idx], + weight_l[cstr_idx], + weight_r[cstr_idx]); base_feas_sum += cstr_base_feas; bonus_robust_sum += cstr_bonus_robust; diff --git a/skills/cuopt-developer/references/conventions.md b/skills/cuopt-developer/references/conventions.md index 1bef2bbe3a..74fac403bf 100644 --- a/skills/cuopt-developer/references/conventions.md +++ b/skills/cuopt-developer/references/conventions.md @@ -197,6 +197,24 @@ rmm::device_uvector data(100, stream); Read existing code in `cpp/src/` for real examples of RMM allocation, stream-ordering, RAFT utilities, and kernel launch patterns. +### Bypassing `ins_vector`: credit the bytes back to the wrapper + +The instrumented accessors record a load per element read, and the counter lives in the +wrapper while the data lives in the vector's buffer. The compiler cannot prove those do not +alias, so the counter round-trips through memory every iteration and serializes the loop. +That cost is measurable in the innermost scoring loops. + +Two instrumentation-free paths to the same buffers already exist: `data()` on the wrapper +returns the raw pointer without recording, and the spans published on `fj_cpu.view` alias the +same allocations. + +When you take either path, add the skipped bytes back into the wrapper you bypassed — +`byte_loads` and `byte_stores` are public `mutable size_t` on +`memory_instrumentation_base_t`, so one `+= n * sizeof(element)` above the loop replaces N +per-element records. Do not route them into a separate counter: the byte totals feed the +deterministic work-unit proxy, and crediting the wrapper keeps both `collect()` and +`collect_per_wrapper()` correct and leaves the work-unit calibration untouched. + ## Test Impact Check **Before any behavioral change, ask:** From 08a858aaf67d4878b96efb06607b03a5a951adbd Mon Sep 17 00:00:00 2001 From: yboucher Date: Fri, 21 Aug 2026 09:11:34 -0700 Subject: [PATCH 52/78] weight_escalation_delta --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index d05c721c00..379ce7da59 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -655,9 +655,7 @@ static inline std::pair compute_score(fj_cpu_climber_t 0 && obj_diff != 0) { - // Scaling base by the objective magnitude only means something where there is feasibility - // impact to trade against; at base_feas_sum zero it would only make base distinct across - // candidates, which strands the bonus stage of the staged comparison. + // Scaling base is only meaningful where there is feasibility impact to trade against. f_t weighted = fj_cpu.h_objective_weight; if (base_feas_sum != 0) { cuopt_assert(fj_cpu.obj_magnitude > 0, "objective magnitude unit must be positive"); @@ -967,6 +965,19 @@ static void smooth_weights(fj_cpu_climber_t& fj_cpu) } } +// Escalation threshold and step for the violated-row bump, in local minima without a severity gain. +constexpr int32_t fj_weight_escalate_after = 2000; +constexpr int32_t fj_weight_escalate_max = 100; + +template +static i_t weight_escalation_delta(const fj_cpu_climber_t& fj_cpu) +{ + const i_t stall = fj_cpu.iters_since_infeasible_improve; + if (stall <= fj_weight_escalate_after) return 1; + const i_t steps = (stall - fj_weight_escalate_after) / fj_weight_escalate_after + 1; + return steps < fj_weight_escalate_max ? steps : fj_weight_escalate_max; +} + template static void update_weights(fj_cpu_climber_t& fj_cpu) { @@ -981,6 +992,8 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) return; } + const i_t escalated_delta = weight_escalation_delta(fj_cpu); + for (auto cstr_idx : fj_cpu.violated_constraints) { f_t curr_incumbent_lhs = fj_cpu.h_lhs[cstr_idx]; f_t curr_lower_excess = @@ -998,7 +1011,7 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) cuopt_assert(curr_excess_score < 0, "constraint not violated"); - i_t int_delta = 1.0; + i_t int_delta = escalated_delta; f_t delta = int_delta; f_t new_weight = old_weight + delta; @@ -1019,9 +1032,7 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) if (fj_cpu.violated_constraints.empty()) { fj_cpu.h_objective_weight += 1; } } -// Applied to the objective weight when a new incumbent lands: the bump it gains, and the ceiling -// it is held at so smooth_weights and update_weights cannot walk it out of scale with the -// feasibility term. +// Bump and ceiling applied to the objective weight when a new incumbent lands. constexpr double fj_obj_weight_incumbent_bump = 4.0; constexpr double fj_obj_weight_incumbent_cap = 64.0; From ff86b7438c2d937487c45ff1e1cd424490f908ae Mon Sep 17 00:00:00 2001 From: yboucher Date: Fri, 21 Aug 2026 09:54:17 -0700 Subject: [PATCH 53/78] infeasible region kicks --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 68 +++++++++++++++---- .../feasibility_jump/fj_cpu_binary.cu | 53 ++++++++++++++- 2 files changed, 105 insertions(+), 16 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 379ce7da59..de159fcd98 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1562,6 +1562,27 @@ static thrust::tuple find_lift_move( return thrust::make_tuple(best_move, best_score); } +// Draws a uniform in-bounds value, rounded and re-clamped for integer variables. +template +static void randomize_variable(fj_cpu_climber_t& fj_cpu, + i_t var_idx, + raft::random::PCGenerator& rng) +{ + f_t lb = std::max(get_lower(fj_cpu.h_var_bounds[var_idx].get()), -1e7); + f_t ub = std::min(get_upper(fj_cpu.h_var_bounds[var_idx].get()), 1e7); + f_t val = lb + (ub - lb) * rng.next_double(); + if (is_integer_var(fj_cpu, var_idx)) { + lb = std::ceil(lb); + ub = std::floor(ub); + val = std::round(val); + val = std::min(std::max(val, lb), ub); + } + + cuopt_assert((check_variable_within_bounds(fj_cpu, var_idx, val)), + "value is out of bounds"); + fj_cpu.h_assignment[var_idx] = val; +} + template static void perturb(fj_cpu_climber_t& fj_cpu) { @@ -1581,21 +1602,8 @@ static void perturb(fj_cpu_climber_t& fj_cpu) fj_cpu.rng); raft::random::PCGenerator rng(fj_cpu.settings.seed + fj_cpu.iterations, 0, 0); - for (auto var_idx : sampled_vars) { - f_t lb = std::max(get_lower(fj_cpu.h_var_bounds[var_idx].get()), -1e7); - f_t ub = std::min(get_upper(fj_cpu.h_var_bounds[var_idx].get()), 1e7); - f_t val = lb + (ub - lb) * rng.next_double(); - if (is_integer_var(fj_cpu, var_idx)) { - lb = std::ceil(lb); - ub = std::floor(ub); - val = std::round(val); - val = std::min(std::max(val, lb), ub); - } - - cuopt_assert((check_variable_within_bounds(fj_cpu, var_idx, val)), - "value is out of bounds"); - fj_cpu.h_assignment[var_idx] = val; - } + for (auto var_idx : sampled_vars) + randomize_variable(fj_cpu, var_idx, rng); recompute_lhs(fj_cpu); } @@ -1626,6 +1634,11 @@ static void restart_from_infeasible_checkpoint(fj_cpu_climber_t& fj_cp invalidate_mtm_cache(fj_cpu); } +// Nonzeros per extra restart window, the cap on that, and how many windows a lane waits. +constexpr int32_t fj_restart_window_nnz_scale = 100000; +constexpr int32_t fj_restart_window_scale_max = 4; +constexpr int32_t fj_restart_window_multiple = 4; + template static void track_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) { @@ -1650,6 +1663,31 @@ static void track_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) return; } + // A lane that has never crossed and has exhausted its restores abandons the basin outright. + if (!fj_cpu.feasible_found) { + const i_t nnz_scale = + 1 + (i_t)fj_cpu.h_coefficients.size() / fj_restart_window_nnz_scale; + const i_t capped = nnz_scale < fj_restart_window_scale_max ? nnz_scale + : fj_restart_window_scale_max; + if (fj_cpu.iters_since_infeasible_improve >= + fj_restart_window_multiple * fj_cpu.infeasible_restart_window * capped && + fj_cpu.restores_since_improvement >= fj_cpu.infeasible_restart_max_streak) { + raft::random::PCGenerator rng(fj_cpu.settings.seed + fj_cpu.iterations, 0, 0); + for (i_t var_idx = 0; var_idx < fj_cpu.view.pb.n_variables; ++var_idx) + randomize_variable(fj_cpu, var_idx, rng); + + recompute_lhs(fj_cpu); + invalidate_mtm_cache(fj_cpu); + reset_infeasible_checkpoint(fj_cpu); + fj_cpu.restores_since_improvement = 0; + + CUOPT_LOG_DEBUG("%sCPUFJ randomized restart at iteration %d", + fj_cpu.log_prefix.c_str(), + fj_cpu.iterations); + return; + } + } + if (fj_cpu.restores_since_improvement >= fj_cpu.infeasible_restart_max_streak) return; if (++fj_cpu.iters_since_infeasible_improve < fj_cpu.infeasible_restart_window) return; if (severity <= fj_cpu.best_infeasible_severity * fj_cpu.infeasible_restart_degrade_ratio) return; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 65ccbf1d4a..023d433b5d 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -209,6 +209,13 @@ constexpr int32_t fj_bin_ddfw_escalate_max = 100; // The same, in feasible local minima without a best-objective improvement. constexpr int32_t fj_bin_obj_stall_after = 50; constexpr int32_t fj_bin_obj_escalate_max = 10; + +// Infeasible-region kick: stall, cooldown, post-restart quiet window, rows drawn, flips per row. +constexpr int32_t fj_bin_kick_after = 200; +constexpr int32_t fj_bin_kick_cooldown = 200; +constexpr int32_t fj_bin_kick_restart_guard = 50; +constexpr int32_t fj_bin_kick_rows = 3; +constexpr int32_t fj_bin_kick_vars_per_row = 2; // prefetch distance // TODO: check if it actually matters at all for performance constexpr int32_t fj_bin_pf_dist = 8; @@ -605,6 +612,7 @@ struct fj_bin_engine_t { int32_t iters{0}; int32_t last_feasible_entrance_iter{0}; int32_t last_restart_iter{0}; + int32_t last_kick_iter{0}; int64_t nnz_touched{0}; // Denominator for the ops-per-nnz roofline: nonzeros the row kernel actually processes, and the @@ -1202,6 +1210,39 @@ struct fj_bin_engine_t { return {best_v, best_s}; } + // Flips a few variables drawn from violated rows, to leave a basin the weights cannot escape. + void infeasible_region_kick() + { + const int32_t n_viol = (int32_t)violated_list.size(); + cuopt_assert(n_viol > 0, "kick requires a violated row"); + + int32_t flipped[fj_bin_kick_rows * fj_bin_kick_vars_per_row]; + int32_t n_flipped = 0; + + for (int32_t i = 0; i < fj_bin_kick_rows; ++i) { + const int32_t r = violated_list[rng.next_u32() % (uint32_t)n_viol]; + const int32_t row_begin = pb.offsets[r]; + const int32_t row_end = pb.offsets[r + 1]; + if (row_begin >= row_end) continue; + + for (int32_t j = 0; j < fj_bin_kick_vars_per_row; ++j) { + const int32_t k = row_begin + (int32_t)(rng.next_u32() % (uint32_t)(row_end - row_begin)); + const int32_t v = pb.variables[k]; + + bool already = false; + for (int32_t f = 0; f < n_flipped && !already; ++f) + already = flipped[f] == v; + if (already) continue; + + cuopt_assert(n_flipped < fj_bin_kick_rows * fj_bin_kick_vars_per_row, "flip list overflow"); + flipped[n_flipped++] = v; + assign[v] = (int8_t)(1 - assign[v]); + assign_i32[v] = assign[v]; + } + } + recompute_slack(); + } + void perturb() { if (pb.objective_vars.empty()) return; @@ -1307,6 +1348,7 @@ struct fj_bin_engine_t { feasible_found = false; iters = 0; last_restart_iter = 0; + last_kick_iter = 0; recompute_slack(); } @@ -1341,7 +1383,16 @@ struct fj_bin_engine_t { apply_move(move_var, (int8_t)(1 - 2 * assign[move_var]), climber); } else { update_weights(); - if (perturb_now) perturb(); + const bool kick_ready = !violated_list.empty() && + iters_since_infeasible_improve >= fj_bin_kick_after && + iters - last_kick_iter >= fj_bin_kick_cooldown && + iters - last_restart_iter >= fj_bin_kick_restart_guard; + if (kick_ready) { + infeasible_region_kick(); + last_kick_iter = iters; + } else if (perturb_now) { + perturb(); + } std::tie(move_var, score) = find_move_violated(1, true); const int32_t v = move_var >= 0 ? move_var : 0; apply_move(v, (int8_t)(1 - 2 * assign[v]), climber); From 4ff4650fc56589f6cb9d52909b7461c40dd200cc Mon Sep 17 00:00:00 2001 From: yboucher Date: Fri, 21 Aug 2026 10:14:14 -0700 Subject: [PATCH 54/78] 2opt lift move thing --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 139 +++++++++++++++++- .../feasibility_jump/fj_cpu_binary.cu | 104 ++++++++++++- 2 files changed, 236 insertions(+), 7 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index de159fcd98..7658583c54 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1435,6 +1435,125 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) } +// Candidate draws per 2-opt lift search. +constexpr int32_t fj_2opt_candidates = 32; + +// True when flipping both variables leaves every row they touch satisfied. Both reverse ranges are +// row-ascending, so a merge handles rows containing both variables with their joint delta. +template +static bool paired_flip_keeps_feasible( + fj_cpu_climber_t& fj_cpu, i_t var1, f_t delta1, i_t var2, f_t delta2) +{ + const auto range1 = reverse_range_for_var(fj_cpu, var1); + const auto range2 = reverse_range_for_var(fj_cpu, var2); + i_t i = range1.first, ie = range1.second; + i_t j = range2.first, je = range2.second; + + while (i < ie || j < je) { + const i_t r1 = i < ie ? (i_t)fj_cpu.h_reverse_constraints[i] : std::numeric_limits::max(); + const i_t r2 = j < je ? (i_t)fj_cpu.h_reverse_constraints[j] : std::numeric_limits::max(); + const i_t r = r1 < r2 ? r1 : r2; + + f_t change = 0; + f_t c_lb = 0; + f_t c_ub = 0; + if (r1 == r) { + auto [lb, ub] = fj_cpu.cached_cstr_bounds[i].get(); + c_lb = lb; + c_ub = ub; + change += (f_t)fj_cpu.h_reverse_coefficients[i] * delta1; + ++i; + } + if (r2 == r) { + auto [lb, ub] = fj_cpu.cached_cstr_bounds[j].get(); + c_lb = lb; + c_ub = ub; + change += (f_t)fj_cpu.h_reverse_coefficients[j] * delta2; + ++j; + } + + const f_t new_lhs = fj_cpu.h_lhs[r] + (change - fj_cpu.h_lhs_sumcomp[r]); + if (fj_cpu.view.excess_score(r, new_lhs, c_lb, c_ub) < + -fj_cpu.view.get_corrected_tolerance(r, c_lb, c_ub)) + return false; + } + return true; +} + +template +static thrust::tuple find_lift_2opt_move( + fj_cpu_climber_t& fj_cpu) +{ + timing_raii_t timer(fj_cpu.find_lift_move_times); + CPUFJ_NVTX_RANGE("CPUFJ::find_lift_2opt_move"); + cuopt_assert(fj_cpu.violated_constraints.empty(), "lift moves require a feasible incumbent"); + + fj_move_t best_first = fj_move_t{-1, 0}; + fj_move_t best_second = fj_move_t{-1, 0}; + fj_staged_score_t best_score = fj_staged_score_t::zero(); + + const i_t n_obj = (i_t)fj_cpu.h_objective_vars.size(); + if (n_obj == 0) return thrust::make_tuple(best_first, best_second, best_score); + + raft::random::PCGenerator rng(fj_cpu.settings.seed + fj_cpu.iterations, 0, 0); + const i_t n_draws = n_obj < fj_2opt_candidates ? n_obj : fj_2opt_candidates; + + for (i_t t = 0; t < n_draws; ++t) { + const i_t var1 = fj_cpu.h_objective_vars[rng.next_u32() % (uint32_t)n_obj]; + if (!fj_cpu.h_is_binary_variable[var1]) continue; + + const f_t coeff1 = fj_cpu.h_obj_coeffs[var1]; + const f_t val1 = fj_cpu.h_assignment[var1]; + const f_t delta1 = round(1.0 - 2 * val1); + if (delta1 * coeff1 >= 0) continue; + if (tabu_check(fj_cpu, var1, delta1)) continue; + + // Breaking nothing is the single-flip lift's job; breaking several rows cannot be repaired by + // one companion. + const auto range1 = reverse_range_for_var(fj_cpu, var1); + i_t broken = -1; + bool multiple = false; + for (i_t k = range1.first; k < range1.second && !multiple; ++k) { + auto [c_lb, c_ub] = fj_cpu.cached_cstr_bounds[k].get(); + const i_t r = fj_cpu.h_reverse_constraints[k]; + const f_t new_lhs = fj_cpu.h_lhs[r] + ((f_t)fj_cpu.h_reverse_coefficients[k] * delta1 - + fj_cpu.h_lhs_sumcomp[r]); + if (fj_cpu.view.excess_score(r, new_lhs, c_lb, c_ub) < + -fj_cpu.view.get_corrected_tolerance(r, c_lb, c_ub)) { + if (broken >= 0) + multiple = true; + else + broken = r; + } + } + if (multiple || broken < 0) continue; + + const auto row = range_for_constraint(fj_cpu, broken); + for (i_t k = row.first; k < row.second; ++k) { + const i_t var2 = fj_cpu.h_variables[k]; + if (var2 == var1) continue; + if (!fj_cpu.h_is_binary_variable[var2]) continue; + + const f_t coeff2 = fj_cpu.h_obj_coeffs[var2]; + const f_t val2 = fj_cpu.h_assignment[var2]; + const f_t delta2 = round(1.0 - 2 * val2); + const f_t combined = delta1 * coeff1 + delta2 * coeff2; + if (combined >= 0) continue; + if (tabu_check(fj_cpu, var2, delta2)) continue; + if (!paired_flip_keeps_feasible(fj_cpu, var1, delta1, var2, delta2)) continue; + + fj_staged_score_t score = fj_staged_score_t::zero(); + score.base = round(-combined); + if (best_score < score) { + best_score = score; + best_first = fj_move_t{var1, delta1}; + best_second = fj_move_t{var2, delta2}; + } + } + } + return thrust::make_tuple(best_first, best_second, best_score); +} + template static thrust::tuple find_lift_move( fj_cpu_climber_t& fj_cpu) @@ -2294,9 +2413,23 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w bool is_mtm_sat = false; // Perform lift moves + fj_move_t lift_companion = fj_move_t{-1, 0}; if (fj_cpu->violated_constraints.empty()) { thrust::tie(move, score) = find_lift_move(*fj_cpu); - if (score > fj_staged_score_t::zero()) is_lift = true; + if (score > fj_staged_score_t::zero()) { + is_lift = true; + } else { + // Pairs are only reachable once no single improving flip preserves feasibility. + fj_move_t first, second; + fj_staged_score_t pair_score; + thrust::tie(first, second, pair_score) = find_lift_2opt_move(*fj_cpu); + if (pair_score > fj_staged_score_t::zero()) { + move = first; + lift_companion = second; + score = pair_score; + is_lift = true; + } + } } // Regular MTM if (!(score > fj_staged_score_t::zero())) { @@ -2319,6 +2452,10 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w if (score > fj_staged_score_t::zero() && !should_perturb) { apply_move(*fj_cpu, move.var_idx, move.value, false); + if (lift_companion.var_idx >= 0) { + apply_move(*fj_cpu, lift_companion.var_idx, lift_companion.value, false); + fj_cpu->n_lift_moves_window++; + } // Track move types if (is_lift) fj_cpu->n_lift_moves_window++; if (is_mtm_viol) fj_cpu->n_mtm_viol_moves_window++; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 023d433b5d..cdbe16b8bd 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -216,6 +216,9 @@ constexpr int32_t fj_bin_kick_cooldown = 200; constexpr int32_t fj_bin_kick_restart_guard = 50; constexpr int32_t fj_bin_kick_rows = 3; constexpr int32_t fj_bin_kick_vars_per_row = 2; + +// Candidate draws per 2-opt lift search. +constexpr int32_t fj_bin_2opt_candidates = 64; // prefetch distance // TODO: check if it actually matters at all for performance constexpr int32_t fj_bin_pf_dist = 8; @@ -1189,6 +1192,82 @@ struct fj_bin_engine_t { return find_move_in_rows(sample_buf, false); } + // True when flipping both variables leaves every row they touch satisfied. Both reverse ranges + // are row-ascending, so shared rows are handled jointly by merging them. + bool paired_flip_keeps_feasible( + int32_t var1, int8_t delta1, int32_t var2, int8_t delta2) const + { + int32_t i = pb.reverse_offsets[var1], ie = pb.reverse_offsets[var1 + 1]; + int32_t j = pb.reverse_offsets[var2], je = pb.reverse_offsets[var2 + 1]; + + while (i < ie || j < je) { + const int32_t r1 = i < ie ? pb.reverse_constraints[i] : INT32_MAX; + const int32_t r2 = j < je ? pb.reverse_constraints[j] : INT32_MAX; + const int32_t r = r1 < r2 ? r1 : r2; + + int32_t change = 0; + if (r1 == r) change += (int32_t)pb.reverse_coefficients[i++] * delta1; + if (r2 == r) change += (int32_t)pb.reverse_coefficients[j++] * delta2; + if (row_slack[r] - change < 0) return false; + } + return true; + } + + std::pair, int64_t> find_lift_2opt_move() + { + cuopt_assert(violated_list.empty(), "lift moves require a feasible incumbent"); + + std::pair best_pair = {-1, -1}; + int64_t best_s = 0; + if (pb.objective_vars.empty()) return {best_pair, best_s}; + + const uint32_t n_obj = (uint32_t)pb.objective_vars.size(); + const int32_t n_draws = n_obj < (uint32_t)fj_bin_2opt_candidates ? (int32_t)n_obj + : fj_bin_2opt_candidates; + + for (int32_t t = 0; t < n_draws; ++t) { + const int32_t var1 = pb.objective_vars[rng.next_u32() % n_obj]; + const int8_t delta1 = (int8_t)(1 - 2 * assign[var1]); + if ((double)delta1 * pb.objective[var1] >= 0) continue; + if (tabu_blocked(var1, false)) continue; + + // Only pairs are useful here: a flip breaking nothing is already the single-flip lift's job, + // and one breaking several rows cannot be repaired by a single companion. + int32_t broken = -1; + bool multiple = false; + for (int32_t i = pb.reverse_offsets[var1]; i < pb.reverse_offsets[var1 + 1] && !multiple; + ++i) { + const int32_t r = pb.reverse_constraints[i]; + if (row_slack[r] - (int32_t)pb.reverse_coefficients[i] * delta1 < 0) { + if (broken >= 0) + multiple = true; + else + broken = r; + } + } + if (multiple || broken < 0) continue; + + for (int32_t k = pb.offsets[broken]; k < pb.offsets[broken + 1]; ++k) { + const int32_t var2 = pb.variables[k]; + if (var2 == var1) continue; + + const int8_t delta2 = (int8_t)(1 - 2 * assign[var2]); + const double combined = (double)delta1 * pb.objective[var1] + + (double)delta2 * pb.objective[var2]; + if (combined >= 0) continue; + if (tabu_blocked(var2, false)) continue; + if (!paired_flip_keeps_feasible(var1, delta1, var2, delta2)) continue; + + const int64_t s = (int64_t)(-std::llround(combined)) * fj_bin_score_k; + if (s > best_s) { + best_s = s; + best_pair = {var1, var2}; + } + } + } + return {best_pair, best_s}; + } + std::pair find_lift_move() const { cuopt_assert(violated_list.empty(), "lift moves require a feasible incumbent"); @@ -1367,11 +1446,21 @@ struct fj_bin_engine_t { if (iters - last_restart_iter >= fj_bin_restart_period) do_restart(); tabu.maybe_rebase(iters); - int32_t move_var = -1; - int64_t score = fj_bin_score_invalid; - if (violated_list.empty()) std::tie(move_var, score) = find_lift_move(); - if (score <= 0) std::tie(move_var, score) = find_move_global(false); - if (feasible_found && score <= 0) std::tie(move_var, score) = find_move_satisfied(mtm_sat_samples); + int32_t move_var = -1; + int64_t score = fj_bin_score_invalid; + std::pair pair2 = {-1, -1}; + if (violated_list.empty()) { + std::tie(move_var, score) = find_lift_move(); + // Pairs are only reachable once no single improving flip preserves feasibility. + if (score <= 0) { + int64_t pair_score; + std::tie(pair2, pair_score) = find_lift_2opt_move(); + if (pair_score > 0) score = pair_score; + } + } + if (pair2.first < 0 && score <= 0) std::tie(move_var, score) = find_move_global(false); + if (pair2.first < 0 && feasible_found && score <= 0) + std::tie(move_var, score) = find_move_satisfied(mtm_sat_samples); bool perturb_now = false; if (violated_list.empty() && iters - last_feasible_entrance_iter > perturb_interval) { @@ -1379,7 +1468,10 @@ struct fj_bin_engine_t { last_feasible_entrance_iter = iters; } - if (score > 0 && move_var >= 0 && !perturb_now) { + if (pair2.first >= 0 && !perturb_now) { + apply_move(pair2.first, (int8_t)(1 - 2 * assign[pair2.first]), climber); + apply_move(pair2.second, (int8_t)(1 - 2 * assign[pair2.second]), climber); + } else if (score > 0 && move_var >= 0 && !perturb_now) { apply_move(move_var, (int8_t)(1 - 2 * assign[move_var]), climber); } else { update_weights(); From 01b44ae6438a0aa8e2bd959ec1bd92eeee354047 Mon Sep 17 00:00:00 2001 From: yboucher Date: Fri, 21 Aug 2026 10:48:00 -0700 Subject: [PATCH 55/78] portfolio lane seeding --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 252 +++++++++++++++++- 1 file changed, 238 insertions(+), 14 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 7658583c54..9c698e47ea 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -2754,8 +2754,9 @@ std::unique_ptr> init_fj_cpu_standalone( // Early CPUFJ runs while presolve is still probing, so there are no implications to hand it const probing_cache_t* no_implications = nullptr; init_fj_cpu(*fj_cpu, solution, default_weights, default_weights, 0.0, no_implications); - fj_cpu->settings = settings; - fj_cpu->settings.seed = cuopt::seed_generator::get_seed(); + // settings.seed is caller-drawn: seed_generator steps a non-atomic global and this may run + // concurrently across lanes. + fj_cpu->settings = settings; return fj_cpu; } @@ -2773,8 +2774,8 @@ std::unique_ptr> init_fj_cpu_standalone_from_template std::vector default_weights(problem.n_constraints, 1.0); init_fj_cpu_from_template(*fj_cpu, tmpl, problem, default_weights, default_weights, 0.0); - fj_cpu->settings = settings; - fj_cpu->settings.seed = cuopt::seed_generator::get_seed(); + // See init_fj_cpu_standalone: the seed is caller-drawn, not taken from the global generator. + fj_cpu->settings = settings; return fj_cpu; } @@ -2905,6 +2906,201 @@ template void finalize_fj_cpu_host_initialization( const typename mip_solver_settings_t::tolerances_t& tolerances); #endif +// Above this the O(nnz) seed passes eat a meaningful slice of a short budget, so they are skipped. +constexpr int64_t fj_seed_nnz_limit = 8'000'000; + +// Jumps each two-sided variable to whichever bound has fewer rows locking it in that direction. +template +static void apply_lock_weighted_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + const i_t n_variables = fj_cpu.view.pb.n_variables; + for (i_t var_idx = 0; var_idx < n_variables; ++var_idx) { + const f_t lb = get_lower(fj_cpu.h_var_bounds[var_idx].get()); + const f_t ub = get_upper(fj_cpu.h_var_bounds[var_idx].get()); + if (!isfinite(lb) || !isfinite(ub) || lb >= ub) continue; + + i_t lock_up = 0; + i_t lock_down = 0; + const auto range = reverse_range_for_var(fj_cpu, var_idx); + for (i_t i = range.first; i < range.second; ++i) { + const f_t coeff = fj_cpu.h_reverse_coefficients[i]; + const i_t cstr_idx = fj_cpu.h_reverse_constraints[i]; + const bool has_lb = isfinite((f_t)fj_cpu.h_cstr_lb[cstr_idx]); + const bool has_ub = isfinite((f_t)fj_cpu.h_cstr_ub[cstr_idx]); + if (coeff > 0) { + lock_up += has_ub; + lock_down += has_lb; + } else if (coeff < 0) { + lock_up += has_lb; + lock_down += has_ub; + } + } + + f_t new_val = lock_up <= lock_down ? ub : lb; + if (is_integer_var(fj_cpu, var_idx)) new_val = std::round(new_val); + fj_cpu.h_assignment[var_idx] = new_val; + } + + recompute_lhs(fj_cpu); + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// Jumps each bounded objective variable to the bound that minimises its own objective term. +template +static void apply_objective_corner_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + const i_t n_variables = fj_cpu.view.pb.n_variables; + for (i_t var_idx = 0; var_idx < n_variables; ++var_idx) { + const f_t coeff = fj_cpu.h_obj_coeffs[var_idx]; + if (coeff == 0) continue; + + const f_t lb = get_lower(fj_cpu.h_var_bounds[var_idx].get()); + const f_t ub = get_upper(fj_cpu.h_var_bounds[var_idx].get()); + if (!isfinite(lb) || !isfinite(ub) || lb >= ub) continue; + + f_t new_val = coeff > 0 ? lb : ub; + if (is_integer_var(fj_cpu, var_idx)) new_val = std::round(new_val); + fj_cpu.h_assignment[var_idx] = new_val; + } + + recompute_lhs(fj_cpu); + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// A single-variable integer step on a row, with the magnitude of its effect on the row sum. +template +struct row_repair_move_t { + f_t effect; + i_t var; + f_t coeff; + f_t new_val; +}; + +// Collects the unit integer steps that push this row's sum in `direction`, largest effect first. +template +static void collect_row_repair_moves(fj_cpu_climber_t& fj_cpu, + i_t row_begin, + i_t row_end, + f_t direction, + f_t tol, + std::vector>& out) +{ + out.clear(); + for (i_t i = row_begin; i < row_end; ++i) { + const i_t var = fj_cpu.h_variables[i]; + if (!is_integer_var(fj_cpu, var)) continue; + + const f_t coeff = fj_cpu.h_coefficients[i]; + const f_t val = fj_cpu.h_assignment[var]; + const f_t lb = get_lower(fj_cpu.h_var_bounds[var].get()); + const f_t ub = get_upper(fj_cpu.h_var_bounds[var].get()); + const bool is_bin = fj_cpu.h_is_binary_variable[var] != 0; + + // Raising the variable shifts the sum by `direction * coeff`; lowering it by the negation. + const f_t raise = direction * coeff; + if (raise > 0 && val < ub - tol) { + const f_t new_val = is_bin ? (f_t)1 : std::floor(val) + 1; + if (new_val > val && new_val <= ub + tol) out.push_back({raise, var, coeff, new_val}); + } else if (raise < 0 && val > lb + tol) { + const f_t new_val = is_bin ? (f_t)0 : std::ceil(val) - 1; + if (new_val < val && new_val >= lb - tol) out.push_back({-raise, var, coeff, new_val}); + } + } + std::sort(out.begin(), out.end(), [](const row_repair_move_t& a, + const row_repair_move_t& b) { + return a.effect > b.effect; + }); +} + +// Time-boxed greedy row repair. Deliberately myopic, so it reverts unless it strictly reduces the +// violated-row count against the incoming anchor. +template +static void apply_greedy_covering_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + recompute_lhs(fj_cpu); + const i_t baseline_violated = fj_cpu.violated_constraints.size(); + const auto anchor_assignment = fj_cpu.h_assignment; + + const i_t n_constraints = fj_cpu.view.pb.n_constraints; + std::vector row_order(n_constraints); + for (i_t i = 0; i < n_constraints; ++i) + row_order[i] = i; + std::sort(row_order.begin(), row_order.end(), [&](i_t a, i_t b) { + return (fj_cpu.h_offsets[a + 1] - fj_cpu.h_offsets[a]) < + (fj_cpu.h_offsets[b + 1] - fj_cpu.h_offsets[b]); + }); + + const auto started = std::chrono::steady_clock::now(); + const double time_budget_s = 0.4; + const f_t tol = 1e-6; + const i_t max_passes = 2; + std::vector> candidates; + bool out_of_time = false; + + for (i_t pass = 0; pass < max_passes && !out_of_time; ++pass) { + for (i_t k = 0; k < n_constraints; ++k) { + if ((k & 0xFFF) == 0 && + std::chrono::duration(std::chrono::steady_clock::now() - started).count() > + time_budget_s) { + out_of_time = true; + break; + } + const i_t cstr_idx = row_order[k]; + const i_t row_begin = fj_cpu.h_offsets[cstr_idx]; + const i_t row_end = fj_cpu.h_offsets[cstr_idx + 1]; + if (row_begin == row_end) continue; + + const f_t lb = fj_cpu.h_cstr_lb[cstr_idx]; + const f_t ub = fj_cpu.h_cstr_ub[cstr_idx]; + const bool has_lb = isfinite(lb); + const bool has_ub = isfinite(ub); + if (!has_lb && !has_ub) continue; + + f_t sum = 0; + for (i_t i = row_begin; i < row_end; ++i) + sum += (f_t)fj_cpu.h_coefficients[i] * (f_t)fj_cpu.h_assignment[fj_cpu.h_variables[i]]; + + // Equality rows are driven to their bound; one-sided rows only to the side they violate. + const bool is_equality = has_lb && has_ub && std::abs(lb - ub) < tol; + f_t direction = 0; + f_t target = 0; + if (is_equality && std::abs(sum - lb) > tol) { + direction = sum < lb ? (f_t)1 : (f_t)-1; + target = lb; + } else if (has_lb && sum < lb - tol) { + direction = 1; + target = lb; + } else if (has_ub && sum > ub + tol) { + direction = -1; + target = ub; + } else { + continue; + } + + collect_row_repair_moves(fj_cpu, row_begin, row_end, direction, tol, candidates); + for (const auto& m : candidates) { + if (direction > 0 ? sum >= target - tol : sum <= target + tol) break; + const f_t delta = m.new_val - (f_t)fj_cpu.h_assignment[m.var]; + sum += m.coeff * delta; + fj_cpu.h_assignment[m.var] = m.new_val; + } + } + } + + recompute_lhs(fj_cpu); + if ((i_t)fj_cpu.violated_constraints.size() >= baseline_violated) { + fj_cpu.h_assignment = anchor_assignment; + recompute_lhs(fj_cpu); + } + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + // Portfolio construction for the standalone benchmark. Host logic, but it lives // in a .cu because fj_cpu.cuh pulls in raft/util/cuda_dev_essentials.cuh through // solution.cuh, which does not compile under the host compiler. Kept out of the @@ -2926,17 +3122,24 @@ void build_climber_portfolio(problem_t& problem, const f_t obj_weight_ladder[4] = {0, 4, 32, 0}; const f_t obj_weight_floor[4] = {1, 4, 32, 1}; - for (int k = 0; k < n_climbers; ++k) { + for (int k = 0; k < n_climbers; ++k) preemption_flags[k].store(false); - fj_settings_t settings; - settings.seed = (int)(base_seed + k); - // Built serially: the first climber host-copies the problem, the rest clone it. - if (k == 0) { - climbers[k] = init_fj_cpu_standalone(problem, solution, preemption_flags[k], settings); - } else { - climbers[k] = - init_fj_cpu_standalone_from_template(problem, *climbers[0], preemption_flags[k], settings); - } + + // cuopt::seed_generator::get_seed() steps a non-atomic global, so every lane's seed is drawn here + // in lane order before any concurrent construction below. + std::vector lane_seed(n_climbers); + for (int k = 0; k < n_climbers; ++k) + lane_seed[k] = cuopt::seed_generator::get_seed(); + + // Per-lane work that must run identically whether the lane was built serially or in parallel. + auto finish_lane = [&](int k) { + // Fewer-lock corner, objective-favorable corner, greedy row repair; lane 0 keeps the anchor. + if (k % 4 == 1) + apply_lock_weighted_seed(*climbers[k]); + else if (k % 4 == 2) + apply_objective_corner_seed(*climbers[k]); + else if (k % 4 == 3) + apply_greedy_covering_seed(*climbers[k]); // Default: every climber identical apart from its seed and a random draw of the // four sampling parameters. Diversification, decorrelated from the value RNG. @@ -2948,6 +3151,27 @@ void build_climber_portfolio(problem_t& problem, climbers[k]->h_objective_weight = obj_weight_ladder[k % 4]; //climbers[k]->seed_objective_weight = obj_weight_floor[k % 4]; + }; + + // Lane 0 is a genuine dependency: it host-copies the problem and every other lane clones it. + { + fj_settings_t settings; + settings.seed = (int)lane_seed[0]; + climbers[0] = init_fj_cpu_standalone(problem, solution, preemption_flags[0], settings); + finish_lane(0); + } + + // The remaining lanes depend only on lane 0's finished, read-only template, and the O(nnz) clone + // and seed passes are otherwise paid serially on one thread while the other pinned CPUs idle. +#ifdef _OPENMP +#pragma omp parallel for num_threads(std::max(1, n_climbers - 1)) schedule(static) +#endif + for (int k = 1; k < n_climbers; ++k) { + fj_settings_t settings; + settings.seed = (int)lane_seed[k]; + climbers[k] = + init_fj_cpu_standalone_from_template(problem, *climbers[0], preemption_flags[k], settings); + finish_lane(k); } } From af02f91a6f29ebaf5e48fc97ecc62e8d5b2702d6 Mon Sep 17 00:00:00 2001 From: yboucher Date: Fri, 21 Aug 2026 11:16:28 -0700 Subject: [PATCH 56/78] early cpu portfolio --- .../feasibility_jump/early_cpufj.cu | 82 ++++++++--- .../feasibility_jump/early_cpufj.cuh | 6 +- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 133 ++++++++++-------- .../feasibility_jump/fj_cpu.cuh | 10 +- cpp/src/mip_heuristics/mip_constants.hpp | 4 + 5 files changed, 149 insertions(+), 86 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu index e4db66ed1f..47a3711fb5 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu @@ -8,6 +8,10 @@ #include "early_cpufj.cuh" #include +#include + +#include +#include namespace cuopt::mathematical_optimization::mip { @@ -32,41 +36,77 @@ template void early_cpufj_t::start() { // 1: presolve, 1: early GPU FJ, 1: early CPU FJ - if (fj_cpu_ || omp_get_num_threads() < CUOPT_MIP_EARLY_CPUFJ_REQUIRED_THREAD_COUNT) { return; } + if (!climbers_.empty() || omp_get_num_threads() < CUOPT_MIP_EARLY_CPUFJ_REQUIRED_THREAD_COUNT) { + return; + } this->preemption_flag_.store(false); this->start_time_ = std::chrono::steady_clock::now(); - fj_cpu_ = - init_fj_cpu_from_optimization_problem(*this->problem_ptr_, tolerances_, preemption_flag_); - - fj_cpu_->log_prefix = "[Early CPUFJ] "; - - fj_cpu_->improvement_callback = [this](f_t solver_obj, - const std::vector& assignment, - double) { this->try_update_best(solver_obj, assignment); }; - - CUOPT_LOG_DEBUG("Launching early CPUFJ task"); -#pragma omp task shared(fj_cpu_) priority(CUOPT_DEFAULT_TASK_PRIORITY) \ - depend(out : *fj_cpu_) default(none) - cpufj_solve(fj_cpu_.get()); + // Tasks are not preempted, so a lane posted beyond the team size would sit in the queue for the + // whole of presolve without running an iteration. + const int n_lanes = std::min(CUOPT_MIP_EARLY_CPUFJ_MAX_CLIMBERS, omp_get_num_threads()); + const int64_t base_seed = cuopt::seed_generator::get_seed(); + climbers_.resize(n_lanes); + + auto report_incumbent = [this](f_t solver_obj, const std::vector& assignment, double) { + std::lock_guard guard(incumbent_mutex_); + this->try_update_best(solver_obj, assignment); + }; + + // Lane 0 builds the host problem representation and every other lane copies it. All of it + // finishes before the first task is posted, so no lane reads a template another lane is running + // on. seed_generator steps a non-atomic global, which is why the draws stay on this thread. + for (int k = 0; k < n_lanes; ++k) { + if (k == 0) { + climbers_[0] = + init_fj_cpu_from_optimization_problem(*this->problem_ptr_, tolerances_, preemption_flag_); + } else { + fj_settings_t settings; + settings.seed = (int)cuopt::seed_generator::get_seed(); + climbers_[k] = init_fj_cpu_clone(*climbers_[0], preemption_flag_, settings); + } + apply_lane_diversification(*climbers_[k], k, base_seed); + climbers_[k]->log_prefix = "[Early CPUFJ " + std::to_string(k) + "] "; + climbers_[k]->improvement_callback = report_incumbent; + } + + CUOPT_LOG_DEBUG("Launching %d early CPUFJ tasks", n_lanes); + for (int k = 0; k < n_lanes; ++k) { + auto* climber = climbers_[k].get(); +#pragma omp task firstprivate(climber) priority(CUOPT_DEFAULT_TASK_PRIORITY) \ + depend(out : *climber) default(none) + cpufj_solve(climber); + } } template void early_cpufj_t::stop() { - if (!fj_cpu_) { return; } + if (climbers_.empty()) { return; } preemption_flag_.store(true); - fj_cpu_->halted = true; -#pragma omp taskwait depend(in : *fj_cpu_) // Wait for the early CPUFJ task to finish - - CUOPT_LOG_DEBUG("[Early CPUFJ] Stopped after %d iterations, solution_found=%d", - fj_cpu_ ? fj_cpu_->iterations : 0, + // Every lane is told to stop before any wait, otherwise the first wait blocks on a lane that has + // not been asked to exit yet. + for (auto& climber : climbers_) { + climber->halted = true; + } + for (size_t k = 0; k < climbers_.size(); ++k) { +#pragma omp taskwait depend(in : *climbers_[k]) // Wait for each early CPUFJ task to finish + } + + i_t total_iterations = 0; + for (const auto& climber : climbers_) { + total_iterations += climber->iterations; + } + + CUOPT_LOG_DEBUG("[Early CPUFJ] Stopped after %d iterations over %d climbers, solution_found=%d", + total_iterations, + (int)climbers_.size(), this->solution_found_); - fj_cpu_.reset(); + climbers_.clear(); } template diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh index a6cf3057e0..ede2ffe168 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh @@ -12,6 +12,7 @@ #include #include +#include #include namespace cuopt::mathematical_optimization::mip { @@ -37,8 +38,11 @@ class early_cpufj_t : public early_heuristic_t const optimization_problem_t* problem_ptr_; typename mip_solver_settings_t::tolerances_t tolerances_; - std::unique_ptr> fj_cpu_; + std::vector>> climbers_; std::atomic preemption_flag_{false}; + // try_update_best and the incumbent callback behind it are not thread-safe, and every lane + // reports into them from its own task. + std::mutex incumbent_mutex_; }; } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 9c698e47ea..10c0182bb4 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1893,21 +1893,28 @@ static void init_fj_cpu(fj_cpu_climber_t& fj_cpu, template static void init_fj_cpu_from_template(fj_cpu_climber_t& fj_cpu, const fj_cpu_climber_t& tmpl, - problem_t& problem, const std::vector& left_weights, const std::vector& right_weights, f_t objective_weight) { - cuopt_assert(tmpl.h_offsets.size() == static_cast(problem.n_constraints + 1), - "template built on a different problem"); - cuopt_assert(tmpl.h_reverse_offsets.size() == static_cast(problem.n_variables + 1), - "template built on a different problem"); - cuopt_assert(tmpl.h_coefficients.size() == static_cast(problem.nnz), - "template built on a different problem"); - - fj_cpu.view = typename fj_t::climber_data_t::view_t{}; - fj_cpu.view.pb = problem.view(); - fj_cpu.pb_ptr = &problem; + const i_t n_variables = (i_t)tmpl.h_reverse_offsets.size() - 1; + const i_t n_constraints = (i_t)tmpl.h_offsets.size() - 1; + const i_t nnz = (i_t)tmpl.h_coefficients.size(); + + cuopt_assert(n_variables == tmpl.view.pb.n_variables, "template variable count mismatch"); + cuopt_assert(n_constraints == tmpl.view.pb.n_constraints, "template constraint count mismatch"); + cuopt_assert(nnz == tmpl.view.pb.nnz, "template nnz mismatch"); + cuopt_assert(left_weights.size() == static_cast(n_constraints), + "left weight size mismatch"); + cuopt_assert(right_weights.size() == static_cast(n_constraints), + "right weight size mismatch"); + + fj_cpu.view = typename fj_t::climber_data_t::view_t{}; + // Every span the host views cover is re-pointed at this climber's own arrays below. The rest of + // the problem view carries over from the template, which is also what makes this usable on + // climbers built without a problem_t at all. + fj_cpu.view.pb = tmpl.view.pb; + fj_cpu.pb_ptr = tmpl.pb_ptr; fj_cpu.h_reverse_coefficients = tmpl.h_reverse_coefficients; fj_cpu.h_reverse_constraints = tmpl.h_reverse_constraints; @@ -1929,19 +1936,19 @@ static void init_fj_cpu_from_template(fj_cpu_climber_t& fj_cpu, fj_cpu.h_objective_weight = objective_weight; fj_cpu.h_assignment = tmpl.h_assignment; fj_cpu.h_best_assignment = tmpl.h_assignment; - fj_cpu.h_tabu_nodec_until.resize(problem.n_variables, 0); - fj_cpu.h_tabu_noinc_until.resize(problem.n_variables, 0); - fj_cpu.h_tabu_lastdec.resize(problem.n_variables, 0); - fj_cpu.h_tabu_lastinc.resize(problem.n_variables, 0); + fj_cpu.h_tabu_nodec_until.resize(n_variables, 0); + fj_cpu.h_tabu_noinc_until.resize(n_variables, 0); + fj_cpu.h_tabu_lastdec.resize(n_variables, 0); + fj_cpu.h_tabu_lastinc.resize(n_variables, 0); fj_cpu.iterations = 0; finalize_fj_cpu_host_initialization_from_template(fj_cpu, tmpl, - problem.n_variables, - problem.n_constraints, - problem.n_integer_vars, - problem.nnz, - problem.tolerances); + n_variables, + n_constraints, + tmpl.n_integer_vars, + nnz, + tmpl.view.pb.tolerances); } template @@ -2762,18 +2769,17 @@ std::unique_ptr> init_fj_cpu_standalone( } template -std::unique_ptr> init_fj_cpu_standalone_from_template( - problem_t& problem, +std::unique_ptr> init_fj_cpu_clone( const fj_cpu_climber_t& tmpl, std::atomic& preemption_flag, fj_settings_t settings) { - raft::common::nvtx::range scope("init_fj_cpu_standalone_from_template"); + raft::common::nvtx::range scope("init_fj_cpu_clone"); auto fj_cpu = std::make_unique>(preemption_flag); - std::vector default_weights(problem.n_constraints, 1.0); - init_fj_cpu_from_template(*fj_cpu, tmpl, problem, default_weights, default_weights, 0.0); + std::vector default_weights(tmpl.view.pb.n_constraints, 1.0); + init_fj_cpu_from_template(*fj_cpu, tmpl, default_weights, default_weights, f_t{0}); // See init_fj_cpu_standalone: the seed is caller-drawn, not taken from the global generator. fj_cpu->settings = settings; @@ -2857,8 +2863,7 @@ template std::unique_ptr> init_fj_cpu_standalone( solution_t& solution, std::atomic& preemption_flag, fj_settings_t settings); -template std::unique_ptr> init_fj_cpu_standalone_from_template( - problem_t& problem, +template std::unique_ptr> init_fj_cpu_clone( const fj_cpu_climber_t& tmpl, std::atomic& preemption_flag, fj_settings_t settings); @@ -2887,8 +2892,7 @@ template std::unique_ptr> init_fj_cpu_standalone( solution_t& solution, std::atomic& preemption_flag, fj_settings_t settings); -template std::unique_ptr> init_fj_cpu_standalone_from_template( - problem_t& problem, +template std::unique_ptr> init_fj_cpu_clone( const fj_cpu_climber_t& tmpl, std::atomic& preemption_flag, fj_settings_t settings); @@ -3101,6 +3105,39 @@ static void apply_greedy_covering_seed(fj_cpu_climber_t& fj_cpu) fj_cpu.h_best_assignment = fj_cpu.h_assignment; } +// What makes one lane of a CPUFJ portfolio behave differently from another: which corner it starts +// from, how it samples, and how hard it pulls on the objective. Lane 0 keeps the anchor assignment +// so it is the lane every clone is built from. +template +void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, int64_t base_seed) +{ + // Objective pressure across the portfolio, indexed by lane. Lanes 0 and 3 stay pure feasibility + // seekers until they cross, since the objective term only enters the score once the weight is + // positive; their nonzero floor then keeps a pull on the objective afterwards rather than letting + // smooth_weights decay it back to nothing. + const f_t obj_weight_ladder[4] = {0, 4, 32, 0}; + const f_t obj_weight_floor[4] = {1, 4, 32, 1}; + + // Fewer-lock corner, objective-favorable corner, greedy row repair; lane 0 keeps the anchor. + if (lane % 4 == 1) + apply_lock_weighted_seed(climber); + else if (lane % 4 == 2) + apply_objective_corner_seed(climber); + else if (lane % 4 == 3) + apply_greedy_covering_seed(climber); + + // Default: every climber identical apart from its seed and a random draw of the + // four sampling parameters. Diversification, decorrelated from the value RNG. + std::mt19937 rng(base_seed + 7919u * lane); + climber.mtm_viol_samples = std::uniform_int_distribution(15, 50)(rng); + climber.mtm_sat_samples = std::uniform_int_distribution(10, 30)(rng); + climber.nnz_samples = std::uniform_int_distribution(2000, 15000)(rng); + climber.perturb_interval = std::uniform_int_distribution(50, 500)(rng); + + climber.h_objective_weight = obj_weight_ladder[lane % 4]; + //climber.seed_objective_weight = obj_weight_floor[lane % 4]; +} + // Portfolio construction for the standalone benchmark. Host logic, but it lives // in a .cu because fj_cpu.cuh pulls in raft/util/cuda_dev_essentials.cuh through // solution.cuh, which does not compile under the host compiler. Kept out of the @@ -3115,13 +3152,6 @@ void build_climber_portfolio(problem_t& problem, { const int n_climbers = static_cast(climbers.size()); - // Objective pressure across the portfolio, indexed by lane. Lanes 0 and 3 stay pure feasibility - // seekers until they cross, since the objective term only enters the score once the weight is - // positive; their nonzero floor then keeps a pull on the objective afterwards rather than letting - // smooth_weights decay it back to nothing. - const f_t obj_weight_ladder[4] = {0, 4, 32, 0}; - const f_t obj_weight_floor[4] = {1, 4, 32, 1}; - for (int k = 0; k < n_climbers; ++k) preemption_flags[k].store(false); @@ -3131,34 +3161,12 @@ void build_climber_portfolio(problem_t& problem, for (int k = 0; k < n_climbers; ++k) lane_seed[k] = cuopt::seed_generator::get_seed(); - // Per-lane work that must run identically whether the lane was built serially or in parallel. - auto finish_lane = [&](int k) { - // Fewer-lock corner, objective-favorable corner, greedy row repair; lane 0 keeps the anchor. - if (k % 4 == 1) - apply_lock_weighted_seed(*climbers[k]); - else if (k % 4 == 2) - apply_objective_corner_seed(*climbers[k]); - else if (k % 4 == 3) - apply_greedy_covering_seed(*climbers[k]); - - // Default: every climber identical apart from its seed and a random draw of the - // four sampling parameters. Diversification, decorrelated from the value RNG. - std::mt19937 rng(base_seed + 7919u * k); - climbers[k]->mtm_viol_samples = std::uniform_int_distribution(15, 50)(rng); - climbers[k]->mtm_sat_samples = std::uniform_int_distribution(10, 30)(rng); - climbers[k]->nnz_samples = std::uniform_int_distribution(2000, 15000)(rng); - climbers[k]->perturb_interval = std::uniform_int_distribution(50, 500)(rng); - - climbers[k]->h_objective_weight = obj_weight_ladder[k % 4]; - //climbers[k]->seed_objective_weight = obj_weight_floor[k % 4]; - }; - // Lane 0 is a genuine dependency: it host-copies the problem and every other lane clones it. { fj_settings_t settings; settings.seed = (int)lane_seed[0]; climbers[0] = init_fj_cpu_standalone(problem, solution, preemption_flags[0], settings); - finish_lane(0); + apply_lane_diversification(*climbers[0], 0, base_seed); } // The remaining lanes depend only on lane 0's finished, read-only template, and the O(nnz) clone @@ -3169,19 +3177,20 @@ void build_climber_portfolio(problem_t& problem, for (int k = 1; k < n_climbers; ++k) { fj_settings_t settings; settings.seed = (int)lane_seed[k]; - climbers[k] = - init_fj_cpu_standalone_from_template(problem, *climbers[0], preemption_flags[k], settings); - finish_lane(k); + climbers[k] = init_fj_cpu_clone(*climbers[0], preemption_flags[k], settings); + apply_lane_diversification(*climbers[k], k, base_seed); } } #if MIP_INSTANTIATE_FLOAT +template void apply_lane_diversification(fj_cpu_climber_t&, int, int64_t); template void build_climber_portfolio( problem_t&, solution_t&, std::vector>&, std::vector>>&, int64_t); #endif #if MIP_INSTANTIATE_DOUBLE +template void apply_lane_diversification(fj_cpu_climber_t&, int, int64_t); template void build_climber_portfolio( problem_t&, solution_t&, std::vector>&, std::vector>>&, int64_t); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 6b4d0f0f76..13eb1778f5 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -317,13 +317,19 @@ std::unique_ptr> init_fj_cpu_standalone( std::atomic& preemption_flag, fj_settings_t settings = fj_settings_t{}); +// Copies a climber that has already paid the O(nnz) problem construction. Everything the engine +// reads is host-owned, so this needs neither a problem handle nor any GPU work. template -std::unique_ptr> init_fj_cpu_standalone_from_template( - problem_t& problem, +std::unique_ptr> init_fj_cpu_clone( const fj_cpu_climber_t& tmpl, std::atomic& preemption_flag, fj_settings_t settings = fj_settings_t{}); +// Per-lane behaviour for a CPUFJ portfolio, shared by every caller that races several climbers so +// the composition cannot drift between them. +template +void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, int64_t base_seed); + // Builds the climber portfolio the standalone benchmark races: how many distinct // behaviours, what parameters each gets, whether they are randomized or // specialized. Defined in fj_cpu_portfolio.cpp -- host code, compiled by the host diff --git a/cpp/src/mip_heuristics/mip_constants.hpp b/cpp/src/mip_heuristics/mip_constants.hpp index f3fb68343a..d09fa710c9 100644 --- a/cpp/src/mip_heuristics/mip_constants.hpp +++ b/cpp/src/mip_heuristics/mip_constants.hpp @@ -21,6 +21,10 @@ #define CUOPT_MIP_BATCH_PDLP_REQUIRED_THREAD_COUNT 3 #define CUOPT_MIP_CLIQUE_CUTS_REQUIRED_THREAD_COUNT 3 +/* @brief Upper bound on the early CPUFJ climber portfolio. Every lane holds its own host copy of + * the problem and occupies an OMP task for the whole of presolve. */ +#define CUOPT_MIP_EARLY_CPUFJ_MAX_CLIMBERS 8 + // MIP-only gate: skip the concurrent barrier when fewer threads are available than this // (1 PDLP + 1 dual simplex + 1 barrier). Stand-alone LP always runs all three. #define CUOPT_CONCURRENT_LP_BARRIER_REQUIRED_THREAD_COUNT 3 From 52932910b067ee07ae918212ddc2cd9ccb57cac1 Mon Sep 17 00:00:00 2001 From: yboucher Date: Sat, 22 Aug 2026 07:42:50 -0700 Subject: [PATCH 57/78] lift move handle fractionals --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 34 +++++++++++-------- .../feasibility_jump/fj_cpu_binary.cu | 30 ++++++++++------ 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 10c0182bb4..6e9fc4e641 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1491,6 +1491,7 @@ static thrust::tuple find_lift_2opt_mov fj_move_t best_first = fj_move_t{-1, 0}; fj_move_t best_second = fj_move_t{-1, 0}; fj_staged_score_t best_score = fj_staged_score_t::zero(); + f_t best_improvement = 0; const i_t n_obj = (i_t)fj_cpu.h_objective_vars.size(); if (n_obj == 0) return thrust::make_tuple(best_first, best_second, best_score); @@ -1542,15 +1543,19 @@ static thrust::tuple find_lift_2opt_mov if (tabu_check(fj_cpu, var2, delta2)) continue; if (!paired_flip_keeps_feasible(fj_cpu, var1, delta1, var2, delta2)) continue; - fj_staged_score_t score = fj_staged_score_t::zero(); - score.base = round(-combined); - if (best_score < score) { - best_score = score; - best_first = fj_move_t{var1, delta1}; - best_second = fj_move_t{var2, delta2}; + // Both lift operators rank on the objective gain in its own units: the score quantization + // used elsewhere counts weights, so rounding a gain below 0.5 into it discards the move. + const f_t improvement = -combined; + if (improvement > best_improvement) { + best_improvement = improvement; + best_score.base = 1; // sign only, never compared against another operator's score + best_first = fj_move_t{var1, delta1}; + best_second = fj_move_t{var2, delta2}; } } } + cuopt_assert((best_first.var_idx < 0) == (best_improvement <= 0), + "pair and score must agree on whether a move was found"); return thrust::make_tuple(best_first, best_second, best_score); } @@ -1563,6 +1568,7 @@ static thrust::tuple find_lift_move( fj_move_t best_move = fj_move_t{-1, 0}; fj_staged_score_t best_score = fj_staged_score_t::zero(); + f_t best_improvement = 0; for (auto var_idx : fj_cpu.h_objective_vars) { cuopt_assert(var_idx < fj_cpu.h_obj_coeffs.size(), "var_idx is out of bounds"); @@ -1666,18 +1672,16 @@ static thrust::tuple find_lift_move( cuopt_assert(delta * obj_coeff < 0, "lift move doesn't improve the objective!"); - // get the score - auto move = fj_move_t{var_idx, delta}; - fj_staged_score_t score = fj_staged_score_t::zero(); - f_t obj_score = -1 * obj_coeff * delta; // negated to turn this into a positive score - score.base = round(obj_score); - - if (best_score < score) { - best_score = score; - best_move = move; + const f_t improvement = -obj_coeff * delta; + if (improvement > best_improvement) { + best_improvement = improvement; + best_score.base = 1; + best_move = fj_move_t{var_idx, delta}; } } + cuopt_assert((best_move.var_idx < 0) == (best_improvement <= 0), + "move and score must agree on whether a move was found"); return thrust::make_tuple(best_move, best_score); } diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index cdbe16b8bd..2af49ef8ff 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -1219,6 +1219,7 @@ struct fj_bin_engine_t { std::pair best_pair = {-1, -1}; int64_t best_s = 0; + double best_improvement = 0; if (pb.objective_vars.empty()) return {best_pair, best_s}; const uint32_t n_obj = (uint32_t)pb.objective_vars.size(); @@ -1258,13 +1259,18 @@ struct fj_bin_engine_t { if (tabu_blocked(var2, false)) continue; if (!paired_flip_keeps_feasible(var1, delta1, var2, delta2)) continue; - const int64_t s = (int64_t)(-std::llround(combined)) * fj_bin_score_k; - if (s > best_s) { - best_s = s; - best_pair = {var1, var2}; + // Both lift operators rank on the objective gain in its own units: the packed score counts + // weights, and this engine requires an integral matrix but not integral objective terms. + const double improvement = -combined; + if (improvement > best_improvement) { + best_improvement = improvement; + best_s = 1; // sign only, never compared against another operator's score + best_pair = {var1, var2}; } } } + cuopt_assert((best_pair.first < 0) == (best_improvement <= 0), + "pair and score must agree on whether a move was found"); return {best_pair, best_s}; } @@ -1272,20 +1278,24 @@ struct fj_bin_engine_t { { cuopt_assert(violated_list.empty(), "lift moves require a feasible incumbent"); - int32_t best_v = -1; - int64_t best_s = 0; + int32_t best_v = -1; + int64_t best_s = 0; + double best_improvement = 0; for (int32_t v : pb.objective_vars) { const int8_t delta = (int8_t)(1 - 2 * assign[v]); if ((double)delta * pb.objective[v] >= 0) continue; if (tabu_blocked(v, false)) continue; // Base field is zero iff the flip breaks no row; K/2 splits it while |bonus| < 2^31. if (var_score[v] <= -(fj_bin_score_k / 2)) continue; - const int64_t s = (int64_t)(-std::llround(pb.objective[v] * delta)) * fj_bin_score_k; - if (s > best_s) { - best_s = s; - best_v = v; + const double improvement = -pb.objective[v] * (double)delta; + if (improvement > best_improvement) { + best_improvement = improvement; + best_s = 1; + best_v = v; } } + cuopt_assert((best_v < 0) == (best_improvement <= 0), + "move and score must agree on whether a move was found"); return {best_v, best_s}; } From a8bb991278bb808e943d2b0a9415aacd7cc0f0f8 Mon Sep 17 00:00:00 2001 From: yboucher Date: Sat, 22 Aug 2026 08:21:04 -0700 Subject: [PATCH 58/78] vectorized argmax for binary path in feasible region --- .../feasibility_jump/fj_cpu_binary.cu | 91 ++++++++++++++++--- 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 2af49ef8ff..a80240f336 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -585,6 +585,13 @@ struct fj_bin_engine_t { std::vector var_score; // live feasibility score of flipping each variable std::vector nnz_score_delta; // per CSR nnz: last score delta of variables[k] in its row + // Objective half of the move score, held live so a weighted global scan can stay vectorized. + // Its support is pb.objective_vars, so entries outside that set are zero for the whole solve. + std::vector obj_base_score; + std::vector combined_score; + // Objective weight obj_base_score was built for; -1 marks it stale. + int32_t obj_base_weight{-1}; + fj_bin_tabu_t tabu; std::vector is_violated; @@ -782,23 +789,32 @@ struct fj_bin_engine_t { for (int32_t v = 0; v < pb.n_variables; ++v) incumbent_objective += pb.objective[v] * assign[v]; nnz_touched += pb.nnz; rebuild_scores(); + // Every caller of this reached it by replacing the assignment wholesale, so the cached + // per-variable flip directions no longer describe it. + obj_base_weight = -1; + } + + // Base field of the objective term: the weight, signed by the direction of the gain and scaled by + // how large that gain is against the model's typical coefficient. Depends only on the variable's + // own value and the weight, which is what lets a global scan cache it. + int64_t objective_base(int32_t v, int8_t delta) const + { + const double obj_diff = pb.objective[v] * delta; + if (obj_diff == 0) return 0; + cuopt_assert(obj_magnitude > 0, "objective magnitude unit must be positive"); + const double rel = std::fabs(obj_diff) / obj_magnitude; + const double mult = + rel < fj_obj_mult_min ? fj_obj_mult_min : (rel > fj_obj_mult_max ? fj_obj_mult_max : rel); + const double raw = objective_weight * mult; + cuopt_assert(fj_bin_in_int32(raw), "scaled objective weight out of int32 range"); + const int32_t scaled = (int32_t)std::lround(raw); + return (int64_t)(obj_diff < 0 ? scaled : -scaled) * fj_bin_score_k; } int64_t objective_terms(int32_t v, int8_t delta) const { const double obj_diff = pb.objective[v] * delta; - int32_t base = 0; - if (obj_diff != 0) { - cuopt_assert(obj_magnitude > 0, "objective magnitude unit must be positive"); - const double rel = std::fabs(obj_diff) / obj_magnitude; - const double mult = - rel < fj_obj_mult_min ? fj_obj_mult_min : (rel > fj_obj_mult_max ? fj_obj_mult_max : rel); - const double raw = objective_weight * mult; - cuopt_assert(fj_bin_in_int32(raw), "scaled objective weight out of int32 range"); - const int32_t scaled = (int32_t)std::lround(raw); - base = obj_diff < 0 ? scaled : -scaled; - } - int32_t bonus = 0; + int32_t bonus = 0; const bool old_better = incumbent_objective < best_objective; const bool new_better = incumbent_objective + obj_diff < best_objective; if (!old_better && new_better) { @@ -806,7 +822,20 @@ struct fj_bin_engine_t { } else if (old_better && !new_better) { bonus -= objective_weight; } - return (int64_t)base * fj_bin_score_k + bonus; + return objective_base(v, delta) + bonus; + } + + int64_t flip_objective_base(int32_t v) const + { + return objective_base(v, (int8_t)(1 - 2 * assign[v])); + } + + // Only the objective variables are written: the rest of the array is zero from init onwards. + void ensure_objective_base() + { + if (obj_base_weight == objective_weight) return; + for (int32_t v : pb.objective_vars) obj_base_score[v] = flip_objective_base(v); + obj_base_weight = objective_weight; } int64_t full_score(int32_t v, int8_t delta) const @@ -913,6 +942,9 @@ struct fj_bin_engine_t { assign_i32[var] = new_val; var_score[var] = own_score; incumbent_objective += pb.objective[var] * delta; + // Only this variable's flip direction moved, so a live cache needs one entry rewritten. + if (obj_base_weight == objective_weight && pb.objective[var] != 0) + obj_base_score[var] = flip_objective_base(var); if (violated_list.empty() && incumbent_objective < best_objective) { best_objective = incumbent_objective; @@ -1089,8 +1121,8 @@ struct fj_bin_engine_t { } // Global argmax over every variable, affordable because var_score is maintained live. While the - // objective weight is zero the full score is exactly var_score, which is the vectorized sweep's - // precondition; the objective and local-minimum paths fall to the scalar loop. + // objective weight is zero the full score is exactly var_score; above zero the sweep runs over + // var_score plus the cached objective base. Only the local-minimum path falls to the scalar loop. std::pair find_move_global(bool localmin) { if (!localmin && objective_weight == 0) { @@ -1108,6 +1140,31 @@ struct fj_bin_engine_t { return {v, s}; } + if (!localmin) { + // The breakthrough bonus is deliberately absent from the ranking: it depends on + // incumbent_objective, so no per-variable form of it survives a move, and it occupies the low + // field where it can only separate variables already tied on the base. The winner's score is + // then taken from full_score so the caller sees the true value. + ensure_objective_base(); + const int64_t* const obj_p = obj_base_score.data(); + const int64_t* const var_p = var_score.data(); + int64_t* const comb_p = combined_score.data(); + for (int32_t v = 0; v < pb.n_variables; ++v) + comb_p[v] = var_p[v] + obj_p[v]; + + int32_t saved_var[fj_bin_tabu_t::ring_size]; + int64_t saved_score[fj_bin_tabu_t::ring_size]; + const int32_t blocked = tabu.block_tabu(iters, comb_p, saved_var, saved_score); + + int32_t v = -1; + int64_t s = fj_bin_score_invalid; + fj_bin_argmax(comb_p, pb.n_variables, argmax_tile, v, s); + + fj_bin_tabu_t::unblock_tabu(blocked, comb_p, saved_var, saved_score); + if (v >= 0) s = full_score(v, (int8_t)(1 - 2 * assign[v])); + return {v, s}; + } + int32_t best_v = -1; int64_t best_s = fj_bin_score_invalid; for (int32_t v = 0; v < pb.n_variables; ++v) { @@ -1411,6 +1468,10 @@ struct fj_bin_engine_t { var_score.assign(n, 0); nnz_score_delta.assign(pb.nnz + fj_bin_simd_padding, 0); + // Zeroed once: ensure_objective_base only ever rewrites the objective variables. + obj_base_score.assign(n, 0); + combined_score.assign(n, 0); + obj_base_weight = -1; tabu.resize(n); is_violated.assign(m, 0); vpos.assign(m, -1); From 868142c03aaa8d90e16377d0d0b6c14ba5ef549e Mon Sep 17 00:00:00 2001 From: yboucher Date: Sat, 22 Aug 2026 09:12:20 -0700 Subject: [PATCH 59/78] kahan sum the objective; audit validity in the harness --- .../linear_programming/cuopt/run_cpufj.cu | 105 ++++++++++++++++++ .../mip_heuristics/feasibility_jump/fj_cpu.cu | 63 ++++++----- .../feasibility_jump/fj_cpu.cuh | 3 + 3 files changed, 142 insertions(+), 29 deletions(-) diff --git a/benchmarks/linear_programming/cuopt/run_cpufj.cu b/benchmarks/linear_programming/cuopt/run_cpufj.cu index 781c28236c..1ab13ca28b 100644 --- a/benchmarks/linear_programming/cuopt/run_cpufj.cu +++ b/benchmarks/linear_programming/cuopt/run_cpufj.cu @@ -5,6 +5,8 @@ */ /* clang-format on */ +#include "miplib2017_bks.hpp" + #include #include #include @@ -19,7 +21,9 @@ #include #include +#include #include +#include #include #include #include @@ -203,6 +207,107 @@ int main(int argc, char** argv) r.iterations, r.seconds > 0 ? r.iterations / r.seconds : 0.0); } + // Runs after the measured window closes, so its cost is off the clock. + // Solver space is always a minimisation, so beating the best known is always a smaller value. + const auto bks_user = cuopt_bench::lookup_miplib_bks(path); + const double bks = bks_user ? (double)problem.get_solver_obj_from_user_obj((f_t)*bks_user) : 0.0; + const double bks_slack = std::max(1e-6, std::fabs(bks) * 1e-9); + + int audited = 0, invalid = 0; + std::printf("\n climber | viol rows worst/tol | bnd viol worst/tol | int viol worst/tol |" + " obj drift rel | vs bks\n"); + std::printf("---------+----------------------+---------------------+---------------------+" + "----------------------+----------\n"); + for (int k = 0; k < n_climbers; ++k) { + auto& c = *climbers[k]; + if (c.feasible_found != results[k].crossed) { + std::printf(" %7d | feasible_found=%d disagrees with a reported incumbent=%d\n", + k, + (int)c.feasible_found, + (int)results[k].crossed); + ++invalid; + continue; + } + if (!c.feasible_found) continue; + ++audited; + + const double int_tol = c.view.pb.tolerances.integrality_tolerance; + + i_t rows_over = 0; + double worst_row_ratio = 0.0; + for (i_t r = 0; r < c.view.pb.n_constraints; ++r) { + __float128 activity = 0; + for (i_t j = c.h_offsets[r]; j < c.h_offsets[r + 1]; ++j) { + const i_t var = c.h_variables[j]; + const double coefficient = c.h_coefficients[j]; + const double value = c.h_best_assignment[var]; + activity += (__float128)coefficient * (__float128)value; + } + + const f_t lb = c.h_cstr_lb[r]; + const f_t ub = c.h_cstr_ub[r]; + const __float128 below = (__float128)lb - activity; + const __float128 above = activity - (__float128)ub; + const double excess = (double)std::max(std::max(below, above), (__float128)0); + if (excess <= 0.0) continue; + + const double tol = c.view.get_corrected_tolerance(r, lb, ub); + const double ratio = tol > 0 ? excess / tol : std::numeric_limits::infinity(); + if (ratio > 1.0) ++rows_over; + worst_row_ratio = std::max(worst_row_ratio, ratio); + } + + i_t bounds_over = 0; + i_t integers_over = 0; + double worst_bound_ratio = 0.0; + double worst_integer_ratio = 0.0; + __float128 objective = 0; + for (i_t v = 0; v < c.view.pb.n_variables; ++v) { + auto bounds = c.h_var_bounds[v].get(); + const double x = (double)c.h_best_assignment[v]; + const double out = std::max( + std::max((double)cuopt::get_lower(bounds) - x, x - (double)cuopt::get_upper(bounds)), 0.0); + if (out > int_tol) ++bounds_over; + worst_bound_ratio = std::max(worst_bound_ratio, int_tol > 0 ? out / int_tol : 0.0); + + if (c.view.pb.is_integer_var(v)) { + const double residual = std::fabs(x - std::round(x)); + if (residual > int_tol) ++integers_over; + worst_integer_ratio = std::max(worst_integer_ratio, int_tol > 0 ? residual / int_tol : 0.0); + } + const double coefficient = c.h_obj_coeffs[v]; + objective += (__float128)coefficient * (__float128)x; + } + + // Differenced before narrowing; the drift is smaller than a double ulp of the sum. + const __float128 difference = objective - (__float128)results[k].best_objective; + const double drift = (double)(difference < 0 ? -difference : difference); + const double exact = (double)objective; + const double scale = std::max(std::fabs(exact), 1.0); + const bool below_bks = bks_user && exact < bks - bks_slack; + const bool bad = rows_over > 0 || bounds_over > 0 || integers_over > 0 || below_bks; + if (bad) ++invalid; + std::printf(" %7d | %9d %10.3g | %8d %10.3g | %8d %10.3g | %12.3g %6.1e | %9.3g%s%s\n", + k, + rows_over, + worst_row_ratio, + bounds_over, + worst_bound_ratio, + integers_over, + worst_integer_ratio, + drift, + drift / scale, + bks_user ? exact - bks : 0.0, + below_bks ? " BELOW BKS" : "", + bad ? " INVALID" : ""); + } + std::printf("AUDIT: %d/%d reporting climbers checked, %d invalid, bks %s\n", + audited, + crossed, + invalid, + bks_user ? std::to_string(*bks_user).c_str() + : (cuopt_bench::is_known_infeasible(path) ? "known infeasible" : "unknown")); + std::printf("\nSUMMARY: %d/%d crossed (%.0f%%) wall=%.1fs total_iters=%.0f agg_iters/s=%.0f\n", crossed, n_climbers, diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 6e9fc4e641..8c044a1870 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1118,36 +1118,39 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, // update the assignment and objective proper fj_cpu.h_assignment[var_idx] = new_val; - fj_cpu.h_incumbent_objective += fj_cpu.h_obj_coeffs[var_idx] * delta; - if (fj_cpu.h_incumbent_objective < fj_cpu.h_best_objective && - fj_cpu.violated_constraints.empty()) { - // recompute the LHS values to cancel out accumulation errors, then check if feasibility remains - recompute_lhs(fj_cpu); - if (fj_cpu.violated_constraints.empty() && check_variable_feasibility(fj_cpu)) { - cuopt_assert(fj_cpu.satisfied_constraints.size() == fj_cpu.view.pb.n_constraints, ""); - fj_cpu.h_best_objective = - fj_cpu.h_incumbent_objective - fj_cpu.settings.parameters.breakthrough_move_epsilon; - fj_cpu.h_best_assignment = fj_cpu.h_assignment; - fj_cpu.iterations_since_best = 0; - // DEBUG, and reporting the stored best rather than the pre-epsilon incumbent, - // so it matches the binary path and the end-of-solve incumbent audit. - CUOPT_LOG_DEBUG("%sCPUFJ new incumbent: objective %.17g", - fj_cpu.log_prefix.c_str(), - fj_cpu.h_best_objective); - if (fj_cpu.improvement_callback) { - double current_work_units = fj_cpu.work_units_elapsed.load(std::memory_order_acquire); - fj_cpu.improvement_callback( - fj_cpu.h_incumbent_objective, fj_cpu.h_assignment, current_work_units); - } - fj_cpu.feasible_found = true; - // Counteract the smooth_weights decay for a lane that is actively improving, and hold the - // weight at a scale where base_feas_sum still registers against it. - if (fj_cpu.h_objective_weight > 0) { - fj_cpu.h_objective_weight = - min((f_t)fj_obj_weight_incumbent_cap, - fj_cpu.h_objective_weight + (f_t)fj_obj_weight_incumbent_bump); - } + // Kahan compensated summation, as for h_lhs. The incumbent objective is reported as-is, so it + // cannot carry the drift of a long uncompensated chain of deltas. + const f_t obj_old = fj_cpu.h_incumbent_objective; + const f_t obj_y = fj_cpu.h_obj_coeffs[var_idx] * delta - fj_cpu.h_objective_sumcomp; + const f_t obj_t = obj_old + obj_y; + fj_cpu.h_objective_sumcomp = (obj_t - obj_old) - obj_y; + fj_cpu.h_incumbent_objective = obj_t; + + if (fj_cpu.h_incumbent_objective < fj_cpu.h_best_objective && + fj_cpu.violated_constraints.empty() && check_variable_feasibility(fj_cpu)) { + cuopt_assert(fj_cpu.satisfied_constraints.size() == fj_cpu.view.pb.n_constraints, ""); + fj_cpu.h_best_objective = + fj_cpu.h_incumbent_objective - fj_cpu.settings.parameters.breakthrough_move_epsilon; + fj_cpu.h_best_assignment = fj_cpu.h_assignment; + fj_cpu.iterations_since_best = 0; + // DEBUG, and reporting the stored best rather than the pre-epsilon incumbent, + // so it matches the binary path and the end-of-solve incumbent audit. + CUOPT_LOG_DEBUG("%sCPUFJ new incumbent: objective %.17g", + fj_cpu.log_prefix.c_str(), + fj_cpu.h_best_objective); + if (fj_cpu.improvement_callback) { + double current_work_units = fj_cpu.work_units_elapsed.load(std::memory_order_acquire); + fj_cpu.improvement_callback( + fj_cpu.h_incumbent_objective, fj_cpu.h_assignment, current_work_units); + } + fj_cpu.feasible_found = true; + // Counteract the smooth_weights decay for a lane that is actively improving, and hold the + // weight at a scale where base_feas_sum still registers against it. + if (fj_cpu.h_objective_weight > 0) { + fj_cpu.h_objective_weight = + min((f_t)fj_obj_weight_incumbent_cap, + fj_cpu.h_objective_weight + (f_t)fj_obj_weight_incumbent_bump); } } @@ -1432,6 +1435,7 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) // compute incumbent objective fj_cpu.h_incumbent_objective = thrust::inner_product( fj_cpu.h_assignment.begin(), fj_cpu.h_assignment.end(), fj_cpu.h_obj_coeffs.begin(), 0.); + fj_cpu.h_objective_sumcomp = 0; } @@ -2151,6 +2155,7 @@ static void finalize_fj_cpu_host_initialization_from_template( fj_cpu.satisfied_constraints = tmpl.satisfied_constraints; fj_cpu.total_violations = tmpl.total_violations; fj_cpu.h_incumbent_objective = tmpl.h_incumbent_objective; + fj_cpu.h_objective_sumcomp = tmpl.h_objective_sumcomp; fj_cpu.n_binary_vars = tmpl.n_binary_vars; fj_cpu.n_integer_vars = tmpl.n_integer_vars; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 13eb1778f5..0ebe059d1d 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -188,6 +188,9 @@ struct fj_cpu_climber_t { // Mean absolute nonzero objective coefficient; the unit of the objective score term. f_t obj_magnitude{1}; f_t h_incumbent_objective; + // Kahan compensation for h_incumbent_objective, mirroring h_lhs_sumcomp. Reset wherever the + // objective is re-derived from the assignment. + f_t h_objective_sumcomp{0}; f_t h_best_objective; i_t last_feasible_entrance_iter{0}; i_t iterations; From a2ccbc980cf54bb9029d7fbf6e322cda72211221 Mon Sep 17 00:00:00 2001 From: yboucher Date: Sat, 22 Aug 2026 09:49:01 -0700 Subject: [PATCH 60/78] Binary perturb re-anchors to the incumbent --- cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index a80240f336..b456559192 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -1392,6 +1392,11 @@ struct fj_bin_engine_t { void perturb() { if (pb.objective_vars.empty()) return; + if (feasible_found) { + cuopt_assert((int32_t)best_assign.size() == pb.n_variables, "incumbent size mismatch"); + assign = best_assign; + for (int32_t v = 0; v < pb.n_variables; ++v) assign_i32[v] = assign[v]; + } const uint32_t n = (uint32_t)pb.objective_vars.size(); for (int i = 0; i < 2; ++i) { const int32_t v = pb.objective_vars[rng.next_u32() % n]; From e02bd00b425b45abc382153a0e0a01c7c6ac93a5 Mon Sep 17 00:00:00 2001 From: yboucher Date: Sat, 22 Aug 2026 11:13:05 -0700 Subject: [PATCH 61/78] apply_exact_k_seed --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 8c044a1870..36c529d12e 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -2922,6 +2922,14 @@ template void finalize_fj_cpu_host_initialization( // Above this the O(nnz) seed passes eat a meaningful slice of a short budget, so they are skipped. constexpr int64_t fj_seed_nnz_limit = 8'000'000; +// Cardinality-row detection: coefficient agreement tolerance and the widest row worth peeling. +constexpr double fj_exact_k_tol = 1e-6; +constexpr int32_t fj_exact_k_max_width = 20000; +constexpr double fj_exact_k_budget_s = 0.5; +// The anchor repair only runs when this fraction of the rows is violated, and gets this long. +constexpr int32_t fj_anchor_repair_violated_share = 5; +constexpr double fj_anchor_repair_budget_s = 0.1; + // Jumps each two-sided variable to whichever bound has fewer rows locking it in that direction. template static void apply_lock_weighted_seed(fj_cpu_climber_t& fj_cpu) @@ -3114,6 +3122,160 @@ static void apply_greedy_covering_seed(fj_cpu_climber_t& fj_cpu) fj_cpu.h_best_assignment = fj_cpu.h_assignment; } +// Constructively satisfies the equality rows that read as sum(x) = k over binaries sharing one +// coefficient: pick k members of each, narrowest rows first so the wide ones inherit the choices, +// and within a row the variables appearing in fewest other such rows. +template +static void apply_exact_k_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + const auto started = std::chrono::steady_clock::now(); + auto timed_out = [&] { + return std::chrono::duration(std::chrono::steady_clock::now() - started).count() > + fj_exact_k_budget_s; + }; + + struct exact_k_row_t { + i_t k, begin, end; + }; + std::vector rows; + for (i_t row = 0; row < fj_cpu.view.pb.n_constraints; ++row) { + if ((row & 0xFFF) == 0 && timed_out()) return; + + const f_t lb = fj_cpu.h_cstr_lb[row]; + const f_t ub = fj_cpu.h_cstr_ub[row]; + if (!isfinite(lb) || !isfinite(ub) || std::abs(lb - ub) > fj_exact_k_tol) continue; + + const i_t begin = fj_cpu.h_offsets[row]; + const i_t end = fj_cpu.h_offsets[row + 1]; + if (end - begin < 2 || end - begin > fj_exact_k_max_width) continue; + + const f_t scale = fj_cpu.h_coefficients[begin]; + if (scale <= 0) continue; + bool uniform_binary = true; + for (i_t p = begin; p < end && uniform_binary; ++p) { + const i_t var = fj_cpu.h_variables[p]; + const f_t coeff = fj_cpu.h_coefficients[p]; + const f_t agreement = fj_exact_k_tol * std::max((f_t)1, std::abs(scale)); + uniform_binary = + fj_cpu.h_is_binary_variable[var] && coeff > 0 && std::abs(coeff - scale) <= agreement; + } + if (!uniform_binary) continue; + + const double cardinality = (double)lb / scale; + const i_t k = (i_t)std::lround(cardinality); + if (std::abs(cardinality - k) <= 1e-4 && k >= 0 && k <= end - begin) + rows.push_back({k, begin, end}); + } + if (rows.empty()) return; + + std::sort(rows.begin(), rows.end(), [](const exact_k_row_t& a, const exact_k_row_t& b) { + return a.end - a.begin < b.end - b.begin; + }); + + const i_t n_variables = fj_cpu.view.pb.n_variables; + std::vector degree(n_variables, 0); + for (const auto& row : rows) + for (i_t p = row.begin; p < row.end; ++p) + ++degree[fj_cpu.h_variables[p]]; + + std::vector state(n_variables, -1); + std::vector free_vars; + for (size_t index = 0; index < rows.size(); ++index) { + if ((index & 0xFFF) == 0 && timed_out()) break; + const auto& row = rows[index]; + + i_t selected = 0; + free_vars.clear(); + for (i_t p = row.begin; p < row.end; ++p) { + const i_t var = fj_cpu.h_variables[p]; + selected += state[var] == 1; + if (state[var] < 0) free_vars.push_back(var); + } + const i_t needed = row.k - selected; + if (needed < 0 || (i_t)free_vars.size() < needed) continue; + + std::sort(free_vars.begin(), free_vars.end(), [°ree](i_t a, i_t b) { + return degree[a] < degree[b]; + }); + for (i_t p = 0; p < (i_t)free_vars.size(); ++p) + state[free_vars[p]] = (int8_t)(p < needed); + } + + recompute_lhs(fj_cpu); + const i_t baseline = fj_cpu.violated_constraints.size(); + const auto anchor = fj_cpu.h_assignment; + for (i_t var = 0; var < n_variables; ++var) + if (state[var] >= 0) fj_cpu.h_assignment[var] = state[var]; + + recompute_lhs(fj_cpu); + if ((i_t)fj_cpu.violated_constraints.size() >= baseline) { + fj_cpu.h_assignment = anchor; + recompute_lhs(fj_cpu); + } + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// One repair pass over the violated rows of a start that is mostly violated. Row sums are read from +// the lhs computed on entry, so a row does not see the repairs made for earlier rows; the revert +// below is what keeps that myopia from costing anything. +template +static void repair_difficult_anchor(fj_cpu_climber_t& fj_cpu) +{ + recompute_lhs(fj_cpu); + const i_t baseline = fj_cpu.violated_constraints.size(); + if (baseline == 0 || baseline <= fj_cpu.view.pb.n_constraints / fj_anchor_repair_violated_share) + return; + + const auto started = std::chrono::steady_clock::now(); + const auto anchor = fj_cpu.h_assignment; + const std::vector violated(fj_cpu.violated_constraints.begin(), + fj_cpu.violated_constraints.end()); + std::vector> candidates; + + for (i_t row : violated) { + if (std::chrono::duration(std::chrono::steady_clock::now() - started).count() > + fj_anchor_repair_budget_s) + break; + + const f_t lb = fj_cpu.h_cstr_lb[row]; + const f_t ub = fj_cpu.h_cstr_ub[row]; + f_t sum = fj_cpu.h_lhs[row]; + f_t target = 0; + f_t direction = 0; + if (sum < lb) { + direction = 1; + target = lb; + } else if (sum > ub) { + direction = -1; + target = ub; + } else { + continue; + } + + collect_row_repair_moves(fj_cpu, + fj_cpu.h_offsets[row], + fj_cpu.h_offsets[row + 1], + direction, + fj_exact_k_tol, + candidates); + for (const auto& move : candidates) { + if (direction > 0 ? sum >= target : sum <= target) break; + const f_t delta = move.new_val - (f_t)fj_cpu.h_assignment[move.var]; + sum += move.coeff * delta; + fj_cpu.h_assignment[move.var] = move.new_val; + } + } + + recompute_lhs(fj_cpu); + if ((i_t)fj_cpu.violated_constraints.size() >= baseline) { + fj_cpu.h_assignment = anchor; + recompute_lhs(fj_cpu); + } + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + // What makes one lane of a CPUFJ portfolio behave differently from another: which corner it starts // from, how it samples, and how hard it pulls on the objective. Lane 0 keeps the anchor assignment // so it is the lane every clone is built from. @@ -3175,6 +3337,9 @@ void build_climber_portfolio(problem_t& problem, fj_settings_t settings; settings.seed = (int)lane_seed[0]; climbers[0] = init_fj_cpu_standalone(problem, solution, preemption_flags[0], settings); + // Runs before the clones are taken, so every lane starts from the repaired anchor. + apply_exact_k_seed(*climbers[0]); + repair_difficult_anchor(*climbers[0]); apply_lane_diversification(*climbers[0], 0, base_seed); } From 0e4453f3a10328b0ccb64c631476e91f90ef241d Mon Sep 17 00:00:00 2001 From: yboucher Date: Sat, 22 Aug 2026 11:33:47 -0700 Subject: [PATCH 62/78] more seeds --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 114 ++++++++++++++++-- 1 file changed, 107 insertions(+), 7 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 36c529d12e..21351c1736 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -2922,6 +2922,11 @@ template void finalize_fj_cpu_host_initialization( // Above this the O(nnz) seed passes eat a meaningful slice of a short budget, so they are skipped. constexpr int64_t fj_seed_nnz_limit = 8'000'000; +// The aggressive corner pushes harder than the covering seed: more passes, a longer budget, a +// tighter clock, and it gives up as soon as a pass changes nothing. +constexpr int32_t fj_aggressive_passes = 6; +constexpr double fj_aggressive_budget_s = 0.9; + // Cardinality-row detection: coefficient agreement tolerance and the widest row worth peeling. constexpr double fj_exact_k_tol = 1e-6; constexpr int32_t fj_exact_k_max_width = 20000; @@ -3122,6 +3127,97 @@ static void apply_greedy_covering_seed(fj_cpu_climber_t& fj_cpu) fj_cpu.h_best_assignment = fj_cpu.h_assignment; } +// Repeated one-sided row repair in CSR order. Unlike the covering seed it revisits rows until a +// pass changes nothing, so a repair that breaks a row already visited gets another chance. +template +static void apply_aggressive_constraint_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + const auto started = std::chrono::steady_clock::now(); + auto timed_out = [&] { + return std::chrono::duration(std::chrono::steady_clock::now() - started).count() > + fj_aggressive_budget_s; + }; + + recompute_lhs(fj_cpu); + const i_t baseline = fj_cpu.violated_constraints.size(); + const auto anchor = fj_cpu.h_assignment; + const f_t tol = 1e-6; + std::vector> candidates; + + for (i_t pass = 0; pass < fj_aggressive_passes && !timed_out(); ++pass) { + i_t moves = 0; + for (i_t row = 0; row < fj_cpu.view.pb.n_constraints; ++row) { + if ((row & 0xFF) == 0 && timed_out()) break; + + const i_t begin = fj_cpu.h_offsets[row]; + const i_t end = fj_cpu.h_offsets[row + 1]; + if (begin == end) continue; + + const f_t lb = fj_cpu.h_cstr_lb[row]; + const f_t ub = fj_cpu.h_cstr_ub[row]; + const bool has_lb = isfinite(lb); + const bool has_ub = isfinite(ub); + if (!has_lb && !has_ub) continue; + + f_t sum = 0; + for (i_t p = begin; p < end; ++p) + sum += (f_t)fj_cpu.h_coefficients[p] * (f_t)fj_cpu.h_assignment[fj_cpu.h_variables[p]]; + + f_t direction = 0; + f_t target = 0; + if (has_lb && sum < lb - tol) { + direction = 1; + target = lb; + } else if (has_ub && sum > ub + tol) { + direction = -1; + target = ub; + } else { + continue; + } + + collect_row_repair_moves(fj_cpu, begin, end, direction, tol, candidates); + for (const auto& move : candidates) { + if (direction > 0 ? sum >= target - tol : sum <= target + tol) break; + const f_t delta = move.new_val - (f_t)fj_cpu.h_assignment[move.var]; + sum += move.coeff * delta; + fj_cpu.h_assignment[move.var] = move.new_val; + ++moves; + } + } + if (moves == 0) break; + } + + recompute_lhs(fj_cpu); + if ((i_t)fj_cpu.violated_constraints.size() >= baseline) { + fj_cpu.h_assignment = anchor; + recompute_lhs(fj_cpu); + } + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// Every variable to its lower bound, or its upper where the lower is infinite. +template +static void apply_lower_bound_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + for (i_t var_idx = 0; var_idx < fj_cpu.view.pb.n_variables; ++var_idx) { + auto bounds = fj_cpu.h_var_bounds[var_idx].get(); + const f_t lower = get_lower(bounds); + const f_t upper = get_upper(bounds); + if (!isfinite(lower) && !isfinite(upper)) continue; + + f_t new_val = isfinite(lower) ? lower : upper; + if (is_integer_var(fj_cpu, var_idx)) new_val = std::round(new_val); + fj_cpu.h_assignment[var_idx] = new_val; + } + + recompute_lhs(fj_cpu); + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + // Constructively satisfies the equality rows that read as sum(x) = k over binaries sharing one // coefficient: pick k members of each, narrowest rows first so the wide ones inherit the choices, // and within a row the variables appearing in fewest other such rows. @@ -3289,13 +3385,17 @@ void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, i const f_t obj_weight_ladder[4] = {0, 4, 32, 0}; const f_t obj_weight_floor[4] = {1, 4, 32, 1}; - // Fewer-lock corner, objective-favorable corner, greedy row repair; lane 0 keeps the anchor. - if (lane % 4 == 1) - apply_lock_weighted_seed(climber); - else if (lane % 4 == 2) - apply_objective_corner_seed(climber); - else if (lane % 4 == 3) - apply_greedy_covering_seed(climber); + // One structural start per lane. Lanes 0 and 4 keep the shared anchor, and lane 5 doubles the + // fewer-lock corner until it has a seed of its own. + switch (lane % 8) { + case 1: apply_lock_weighted_seed(climber); break; + case 2: apply_aggressive_constraint_seed(climber); break; + case 3: apply_greedy_covering_seed(climber); break; + case 5: apply_lock_weighted_seed(climber); break; + case 6: apply_objective_corner_seed(climber); break; + case 7: apply_lower_bound_seed(climber); break; + default: break; + } // Default: every climber identical apart from its seed and a random draw of the // four sampling parameters. Diversification, decorrelated from the value RNG. From 6c8e2ce6979c7ef0f0e20061a4c52d6f9dfce1ee Mon Sep 17 00:00:00 2001 From: yboucher Date: Sat, 22 Aug 2026 12:25:41 -0700 Subject: [PATCH 63/78] bipartite seeding --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 240 +++++++++++++++++- 1 file changed, 237 insertions(+), 3 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 21351c1736..8fafbfa8f4 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -2922,6 +2923,10 @@ template void finalize_fj_cpu_host_initialization( // Above this the O(nnz) seed passes eat a meaningful slice of a short budget, so they are skipped. constexpr int64_t fj_seed_nnz_limit = 8'000'000; +// Budget for the matching seed and the widest exact-one row it will take into the graph. +constexpr double fj_matching_budget_s = 0.45; +constexpr int32_t fj_matching_max_row_width = 20000; + // The aggressive corner pushes harder than the covering seed: more passes, a longer budget, a // tighter clock, and it gives up as soon as a pass changes nothing. constexpr int32_t fj_aggressive_passes = 6; @@ -3197,6 +3202,236 @@ static void apply_aggressive_constraint_seed(fj_cpu_climber_t& fj_cpu) fj_cpu.h_best_assignment = fj_cpu.h_assignment; } +// Treats the exact-one rows as a graph in which each variable is an edge between the two rows it +// appears in. A component that is bipartite and has equally many rows on each side admits a perfect +// matching, and the cheapest one is the assignment satisfying every row in the component at least +// cost. Solved per component as min-cost flow by successive shortest paths, which needs no +// potentials here because augmenting along shortest paths keeps the residual free of negative +// cycles. Components that are not of that shape are left to the search. +template +static void apply_bipartite_matching_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + const auto started = std::chrono::steady_clock::now(); + auto timed_out = [&] { + return std::chrono::duration(std::chrono::steady_clock::now() - started).count() > + fj_matching_budget_s; + }; + const f_t tol = 1e-6; + + struct exact_one_row_t { + i_t begin, end; + }; + std::vector rows; + for (i_t row = 0; row < fj_cpu.view.pb.n_constraints; ++row) { + if ((row & 0xFFF) == 0 && timed_out()) return; + + const f_t lb = fj_cpu.h_cstr_lb[row]; + const f_t ub = fj_cpu.h_cstr_ub[row]; + if (!isfinite(lb) || !isfinite(ub) || std::abs(lb - ub) > tol) continue; + + const i_t begin = fj_cpu.h_offsets[row]; + const i_t end = fj_cpu.h_offsets[row + 1]; + if (begin == end || end - begin > fj_matching_max_row_width) continue; + + const f_t scale = fj_cpu.h_coefficients[begin]; + if (!isfinite(scale) || std::abs(scale) <= tol || std::abs(lb / scale - 1) > 1e-5) continue; + + bool uniform_binary = true; + for (i_t p = begin; p < end && uniform_binary; ++p) { + const f_t coeff = fj_cpu.h_coefficients[p]; + const f_t agreement = tol * std::max((f_t)1, std::abs(scale)); + uniform_binary = fj_cpu.h_is_binary_variable[fj_cpu.h_variables[p]] && + std::abs(coeff - scale) <= agreement; + } + if (uniform_binary) rows.push_back({begin, end}); + } + if (rows.size() < 2 || timed_out()) return; + + const i_t n_rows = (i_t)rows.size(); + const i_t n_variables = fj_cpu.view.pb.n_variables; + std::vector degree(n_variables, 0); + std::vector endpoint_a(n_variables, -1); + std::vector endpoint_b(n_variables, -1); + for (i_t row = 0; row < n_rows; ++row) { + for (i_t p = rows[row].begin; p < rows[row].end; ++p) { + const i_t var = fj_cpu.h_variables[p]; + if (degree[var] == 0) endpoint_a[var] = row; + else if (degree[var] == 1) endpoint_b[var] = row; + ++degree[var]; + } + } + + struct edge_t { + int to, reverse, capacity; + f_t cost; + i_t var; + }; + auto add_edge = [](std::vector>& graph, int from, int to, f_t cost, i_t var) { + const int back = (int)graph[to].size(); + graph[from].push_back({to, back, 1, cost, var}); + graph[to].push_back({from, (int)graph[from].size() - 1, 0, -cost, -1}); + }; + + std::vector color(n_rows, -1); + std::vector state(n_variables, -1); + std::vector side_index(n_rows, -1); + std::vector component_rows, component_vars, left, right; + std::queue pending; + bool installed = false; + + for (i_t root = 0; root < n_rows && !timed_out(); ++root) { + if (color[root] >= 0) continue; + + component_rows.clear(); + component_vars.clear(); + color[root] = 0; + pending.push(root); + bool valid = true; + while (!pending.empty()) { + const i_t row = pending.front(); + pending.pop(); + component_rows.push_back(row); + for (i_t p = rows[row].begin; p < rows[row].end; ++p) { + const i_t var = fj_cpu.h_variables[p]; + // A variable outside exactly two rows is not an edge, and a self-loop cannot be 2-coloured. + if (degree[var] != 2 || endpoint_a[var] == endpoint_b[var]) { + valid = false; + continue; + } + if (endpoint_a[var] == row) component_vars.push_back(var); + const i_t other = endpoint_a[var] == row ? endpoint_b[var] : endpoint_a[var]; + if (color[other] < 0) { + color[other] = 1 - color[row]; + pending.push(other); + } else if (color[other] == color[row]) { + valid = false; + } + } + if ((component_rows.size() & 0x3FF) == 0 && timed_out()) return; + } + if (!valid || component_vars.empty()) continue; + + left.clear(); + right.clear(); + for (i_t row : component_rows) + (color[row] == 0 ? left : right).push_back(row); + if (left.size() != right.size()) continue; + for (i_t k = 0; k < (i_t)left.size(); ++k) + side_index[left[k]] = k; + for (i_t k = 0; k < (i_t)right.size(); ++k) + side_index[right[k]] = k; + + const int side = (int)left.size(); + const int source = 2 * side; + const int sink = source + 1; + std::vector> graph(sink + 1); + for (int k = 0; k < side; ++k) { + add_edge(graph, source, k, 0, -1); + add_edge(graph, side + k, sink, 0, -1); + } + // Every perfect matching uses exactly one variable edge per row, so shifting all of them by a + // constant moves every matching's cost equally and leaves the cheapest one unchanged. Shifting + // the negatives away is what lets the potentials below start at zero. + f_t cheapest = 0; + for (i_t var : component_vars) { + const f_t cost = fj_cpu.h_obj_coeffs[var]; + if (!isfinite(cost)) { + valid = false; + break; + } + cheapest = std::min(cheapest, cost); + } + if (!valid) continue; + const f_t shift = -cheapest; + + for (i_t var : component_vars) { + i_t a = endpoint_a[var]; + i_t b = endpoint_b[var]; + if (color[a] == 1) std::swap(a, b); + add_edge(graph, side_index[a], side + side_index[b], fj_cpu.h_obj_coeffs[var] + shift, var); + } + + // Node potentials hold every reduced cost at or above zero, which is what makes Dijkstra + // applicable. All shifted costs start non-negative, so the potentials start at zero. Rounding + // can still leave a tree edge fractionally negative once the potentials move, so relaxation + // below skips settled nodes: that keeps every predecessor older than its successor in + // settlement order, which is what makes the retrace terminate. + int flow = 0; + std::vector potential(graph.size(), 0); + std::vector distance(graph.size()); + std::vector previous_node(graph.size()); + std::vector previous_edge(graph.size()); + std::vector settled(graph.size()); + using heap_entry_t = std::pair; + + while (flow < side && !timed_out()) { + std::fill(distance.begin(), distance.end(), std::numeric_limits::infinity()); + std::fill(previous_node.begin(), previous_node.end(), -1); + std::fill(settled.begin(), settled.end(), 0); + distance[source] = 0; + std::priority_queue, std::greater> heap; + heap.push({0, source}); + + while (!heap.empty()) { + const auto [reached_at, from] = heap.top(); + heap.pop(); + if (settled[from]) continue; + settled[from] = 1; + for (int e = 0; e < (int)graph[from].size(); ++e) { + const auto& edge = graph[from][e]; + if (!edge.capacity || settled[edge.to]) continue; + const f_t reduced = edge.cost + potential[from] - potential[edge.to]; + cuopt_assert(reduced >= -1e-9 * std::max((f_t)1, std::abs(edge.cost)), + "potentials failed to keep the reduced cost non-negative"); + if (reached_at + reduced >= distance[edge.to]) continue; + distance[edge.to] = reached_at + reduced; + previous_node[edge.to] = from; + previous_edge[edge.to] = e; + heap.push({distance[edge.to], edge.to}); + } + } + if (previous_node[sink] < 0) break; + + for (int node = 0; node < (int)graph.size(); ++node) + if (isfinite(distance[node])) potential[node] += distance[node]; + + for (int node = sink; node != source; node = previous_node[node]) { + cuopt_assert(previous_node[node] >= 0, "augmenting path is broken"); + auto& edge = graph[previous_node[node]][previous_edge[node]]; + --edge.capacity; + ++graph[node][edge.reverse].capacity; + } + ++flow; + } + if (flow != side) continue; + + for (i_t var : component_vars) + state[var] = 0; + for (int node = 0; node < side; ++node) + for (const auto& edge : graph[node]) + if (edge.var >= 0 && edge.capacity == 0) state[edge.var] = 1; + installed = true; + } + if (!installed) return; + + recompute_lhs(fj_cpu); + const i_t baseline = fj_cpu.violated_constraints.size(); + const auto anchor = fj_cpu.h_assignment; + for (i_t var = 0; var < n_variables; ++var) + if (state[var] >= 0) fj_cpu.h_assignment[var] = state[var]; + + recompute_lhs(fj_cpu); + const i_t candidate = fj_cpu.violated_constraints.size(); + // Kept when it reaches feasibility outright, otherwise only on a strict gain. + if (candidate != 0 && candidate >= baseline) { + fj_cpu.h_assignment = anchor; + recompute_lhs(fj_cpu); + } + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + // Every variable to its lower bound, or its upper where the lower is infinite. template static void apply_lower_bound_seed(fj_cpu_climber_t& fj_cpu) @@ -3385,13 +3620,12 @@ void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, i const f_t obj_weight_ladder[4] = {0, 4, 32, 0}; const f_t obj_weight_floor[4] = {1, 4, 32, 1}; - // One structural start per lane. Lanes 0 and 4 keep the shared anchor, and lane 5 doubles the - // fewer-lock corner until it has a seed of its own. + // One structural start per lane; lanes 0 and 4 keep the shared anchor. switch (lane % 8) { case 1: apply_lock_weighted_seed(climber); break; case 2: apply_aggressive_constraint_seed(climber); break; case 3: apply_greedy_covering_seed(climber); break; - case 5: apply_lock_weighted_seed(climber); break; + case 5: apply_bipartite_matching_seed(climber); break; case 6: apply_objective_corner_seed(climber); break; case 7: apply_lower_bound_seed(climber); break; default: break; From 32b6b522740b0bbe54cfb968719cfa37dc350287 Mon Sep 17 00:00:00 2001 From: yboucher Date: Sat, 22 Aug 2026 12:45:05 -0700 Subject: [PATCH 64/78] share incumbent in portfolio --- .../feasibility_jump/early_cpufj.cu | 4 ++ .../mip_heuristics/feasibility_jump/fj_cpu.cu | 12 ++++++ .../feasibility_jump/fj_cpu.cuh | 38 +++++++++++++++++++ .../feasibility_jump/fj_cpu_binary.cu | 14 ++++++- 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu index 47a3711fb5..8b1444d8a1 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu @@ -71,6 +71,10 @@ void early_cpufj_t::start() climbers_[k]->improvement_callback = report_incumbent; } + auto shared = std::make_shared>(); + for (int k = 0; k < n_lanes; ++k) + climbers_[k]->shared_incumbent = shared; + CUOPT_LOG_DEBUG("Launching %d early CPUFJ tasks", n_lanes); for (int k = 0; k < n_lanes; ++k) { auto* climber = climbers_[k].get(); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 8fafbfa8f4..ca66be9188 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1146,6 +1146,11 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, fj_cpu.h_incumbent_objective, fj_cpu.h_assignment, current_work_units); } fj_cpu.feasible_found = true; + // The true objective of the assignment, not the epsilon-reduced threshold stored above, so + // another lane comparing against it is not misled into adopting something no better. + if (fj_cpu.shared_incumbent) { + fj_cpu.shared_incumbent->publish(fj_cpu.h_incumbent_objective, fj_cpu.h_assignment); + } // Counteract the smooth_weights decay for a lane that is actively improving, and hold the // weight at a scale where base_feas_sum still registers against it. if (fj_cpu.h_objective_weight > 0) { @@ -1719,6 +1724,9 @@ static void perturb(fj_cpu_climber_t& fj_cpu) cuopt_assert(fj_cpu.h_assignment.size() == fj_cpu.h_best_assignment.size(), "incumbent_assignment span would be invalidated"); fj_cpu.h_assignment = fj_cpu.h_best_assignment; + if (fj_cpu.shared_incumbent) { + fj_cpu.shared_incumbent->adopt(fj_cpu.h_best_objective, fj_cpu.h_assignment); + } } // select N variables, assign them a random value between their bounds @@ -3688,6 +3696,10 @@ void build_climber_portfolio(problem_t& problem, climbers[k] = init_fj_cpu_clone(*climbers[0], preemption_flags[k], settings); apply_lane_diversification(*climbers[k], k, base_seed); } + + auto shared = std::make_shared>(); + for (int k = 0; k < n_climbers; ++k) + climbers[k]->shared_incumbent = shared; } #if MIP_INSTANTIATE_FLOAT diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 0ebe059d1d..d81674c991 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -88,6 +88,40 @@ struct host_contiguous_set_t { constexpr double fj_obj_mult_min = 0.25; constexpr double fj_obj_mult_max = 4.0; +// Best feasible assignment found by any lane of one portfolio. A lane publishes its own +// improvements and adopts a better one when it perturbs, so a lane that has stalled resumes from +// the portfolio's progress instead of its own. Lanes run concurrently, so which lane observes +// which incumbent depends on scheduling: a portfolio that shares is not run-to-run reproducible. +template +struct fj_cpu_shared_incumbent_t { + // True when the candidate beat the shared best, in which case it was stored. + bool publish(f_t candidate_objective, const std::vector& candidate) + { + // Unlocked reject first: the publish sites are hot on instances that improve in tiny steps. + if (!(candidate_objective < objective.load(std::memory_order_relaxed))) return false; + std::lock_guard lock(guard); + if (!(candidate_objective < objective.load(std::memory_order_relaxed))) return false; + assignment = candidate; + objective.store(candidate_objective, std::memory_order_relaxed); + return true; + } + + // True when the shared best beat local_objective, in which case it was copied into destination. + bool adopt(f_t local_objective, std::vector& destination) + { + if (!(objective.load(std::memory_order_relaxed) < local_objective)) return false; + std::lock_guard lock(guard); + if (!(objective.load(std::memory_order_relaxed) < local_objective)) return false; + cuopt_assert(assignment.size() == destination.size(), "shared incumbent size mismatch"); + destination = assignment; + return true; + } + + std::mutex guard; + std::vector assignment; + std::atomic objective{std::numeric_limits::infinity()}; +}; + // NOTE: this seems an easy pick for reflection/xmacros once this is available (C++26?) // Maintaining a single source of truth for all members would be nice template @@ -265,6 +299,10 @@ struct fj_cpu_climber_t { std::function&)> diversity_callback{nullptr}; std::string log_prefix{""}; + // Held with the other lanes of the same portfolio. Null when the climber runs alone, which is + // what keeps a solo climber reproducible. + std::shared_ptr> shared_incumbent; + // Work unit tracking for deterministic synchronization std::atomic work_units_elapsed{0.0}; double work_unit_bias{1.5}; // Bias factor to keep CPUFJ ahead of B&B diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index b456559192..2bb49e9cc3 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -573,6 +573,9 @@ struct fj_bin_engine_t { std::vector assign; std::vector best_assign; + std::shared_ptr> shared_incumbent; + // Staging for an adopted assignment, which arrives as f_t. Sized only when sharing is on. + std::vector adopt_buffer; std::vector seed_assign; // restart target std::vector assign_i32; // gather mirror for the SIMD patch (Batch B) @@ -970,6 +973,7 @@ struct fj_bin_engine_t { climber.h_incumbent_objective = (f_t)incumbent_objective; climber.h_best_objective = (f_t)best_objective; climber.feasible_found = true; + if (shared_incumbent) { shared_incumbent->publish((f_t)best_objective, h_best); } // Emitted once per improvement so the benchmark harness can reconstruct the // incumbent trajectory exactly, rather than sampling it at log_interval. CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] new incumbent: objective %.17g", @@ -1395,6 +1399,10 @@ struct fj_bin_engine_t { if (feasible_found) { cuopt_assert((int32_t)best_assign.size() == pb.n_variables, "incumbent size mismatch"); assign = best_assign; + if (shared_incumbent && shared_incumbent->adopt((f_t)best_objective, adopt_buffer)) { + for (int32_t v = 0; v < pb.n_variables; ++v) + assign[v] = (int8_t)(adopt_buffer[v] >= 0.5 ? 1 : 0); + } for (int32_t v = 0; v < pb.n_variables; ++v) assign_i32[v] = assign[v]; } const uint32_t n = (uint32_t)pb.objective_vars.size(); @@ -1462,8 +1470,10 @@ struct fj_bin_engine_t { const double val = (double)h_assign[v]; assign[v] = (int8_t)(val >= 0.5 ? 1 : 0); } - seed_assign = assign; - best_assign = assign; + seed_assign = assign; + best_assign = assign; + shared_incumbent = climber.shared_incumbent; + if (shared_incumbent) adopt_buffer.assign(n, 0); reset_infeasible_checkpoint(); assign_i32.assign(n, 0); for (int32_t v = 0; v < n; ++v) assign_i32[v] = assign[v]; From 428575adad397105fcb8848a4ffd24f0ddc112da Mon Sep 17 00:00:00 2001 From: yboucher Date: Sat, 22 Aug 2026 13:25:12 -0700 Subject: [PATCH 65/78] perturbation gated on objective stall --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 13 +++----- .../feasibility_jump/fj_cpu.cuh | 1 - .../feasibility_jump/fj_cpu_binary.cu | 30 +++++++++++-------- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index ca66be9188..cccdb9f813 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1071,8 +1071,6 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, fj_cpu.n_variable_updates_window++; fj_cpu.unique_vars_accessed_window.insert(var_idx); - i_t previous_viol = fj_cpu.violated_constraints.size(); - for (auto i = offset_begin; i < offset_end; i++) { cuopt_assert(i < (i_t)fj_cpu.h_reverse_constraints.size(), ""); auto [c_lb, c_ub] = fj_cpu.cached_cstr_bounds[i].get(); @@ -1113,10 +1111,6 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, fj_cpu.h_cstr_version[cstr_idx]++; } - if (previous_viol > 0 && fj_cpu.violated_constraints.empty()) { - fj_cpu.last_feasible_entrance_iter = fj_cpu.iterations; - } - // update the assignment and objective proper fj_cpu.h_assignment[var_idx] = new_val; @@ -2470,9 +2464,10 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w // perturb bool should_perturb = false; if (fj_cpu->violated_constraints.empty() && - fj_cpu->iterations - fj_cpu->last_feasible_entrance_iter > fj_cpu->perturb_interval) { - should_perturb = true; - fj_cpu->last_feasible_entrance_iter = fj_cpu->iterations; + fj_cpu->iterations_since_best > fj_cpu->perturb_interval) { + should_perturb = true; + // Without this the counter stays above the interval and every later iteration perturbs. + fj_cpu->iterations_since_best = 0; } if (score > fj_staged_score_t::zero() && !should_perturb) { diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index d81674c991..054f1b1158 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -226,7 +226,6 @@ struct fj_cpu_climber_t { // objective is re-derived from the assignment. f_t h_objective_sumcomp{0}; f_t h_best_objective; - i_t last_feasible_entrance_iter{0}; i_t iterations; host_contiguous_set_t violated_constraints; host_contiguous_set_t satisfied_constraints; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 2bb49e9cc3..cd8d247153 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -623,7 +623,9 @@ struct fj_bin_engine_t { bool feasible_found{false}; int32_t iters{0}; - int32_t last_feasible_entrance_iter{0}; + // Iterations since best_objective last moved. Counts iterations, unlike + // iterations_at_same_objective, so it is comparable against perturb_interval. + int32_t iters_since_best{0}; int32_t last_restart_iter{0}; int32_t last_kick_iter{0}; int64_t nnz_touched{0}; @@ -854,8 +856,7 @@ struct fj_bin_engine_t { const int8_t new_val = (int8_t)(assign[var] + delta); const int8_t new_flip = (int8_t)(1 - 2 * new_val); const int32_t ob = pb.reverse_offsets[var], oe = pb.reverse_offsets[var + 1]; - const int32_t prev_violated = (int32_t)violated_list.size(); - int64_t own_score = 0; + int64_t own_score = 0; // The tail writes a score delta through int32_t* and calls out to the patch, either of which may // alias a vector's internal pointer as far as the compiler can prove. Without these locals it @@ -939,8 +940,6 @@ struct fj_bin_engine_t { nnz_touched += oe - ob; rows_walked += oe - ob; - if (prev_violated > 0 && violated_list.empty()) last_feasible_entrance_iter = iters; - assign[var] = new_val; assign_i32[var] = new_val; var_score[var] = own_score; @@ -950,9 +949,10 @@ struct fj_bin_engine_t { obj_base_score[var] = flip_objective_base(var); if (violated_list.empty() && incumbent_objective < best_objective) { - best_objective = incumbent_objective; - best_assign = assign; - feasible_found = true; + best_objective = incumbent_objective; + best_assign = assign; + feasible_found = true; + iters_since_best = 0; report_incumbent(climber); } @@ -1426,8 +1426,9 @@ struct fj_bin_engine_t { reset_infeasible_checkpoint(); tabu.clear(iters); recompute_slack(); - last_restart_iter = iters; - last_feasible_entrance_iter = iters; + last_restart_iter = iters; + // The restarted walk gets a full window before the stall gate can perturb it. + iters_since_best = 0; } void init(fj_cpu_climber_t& climber) @@ -1512,6 +1513,7 @@ struct fj_bin_engine_t { iterations_at_same_objective = 0; feasible_found = false; iters = 0; + iters_since_best = 0; last_restart_iter = 0; last_kick_iter = 0; recompute_slack(); @@ -1549,9 +1551,10 @@ struct fj_bin_engine_t { std::tie(move_var, score) = find_move_satisfied(mtm_sat_samples); bool perturb_now = false; - if (violated_list.empty() && iters - last_feasible_entrance_iter > perturb_interval) { - perturb_now = true; - last_feasible_entrance_iter = iters; + if (violated_list.empty() && iters_since_best > perturb_interval) { + perturb_now = true; + // Without this the counter stays above the interval and every later iteration perturbs. + iters_since_best = 0; } if (pair2.first >= 0 && !perturb_now) { @@ -1601,6 +1604,7 @@ struct fj_bin_engine_t { } ++iters; + ++iters_since_best; } compute_saturation(); From 21f2b8972b2de51163e4a4d9f6b69c6bae502e9e Mon Sep 17 00:00:00 2001 From: yboucher Date: Sun, 23 Aug 2026 09:50:18 -0700 Subject: [PATCH 66/78] infeasible pair repair --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 2 + .../feasibility_jump/fj_cpu.cuh | 3 + .../feasibility_jump/fj_cpu_binary.cu | 193 ++++++++++++++++-- 3 files changed, 182 insertions(+), 16 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index cccdb9f813..730f58dee4 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -3642,6 +3642,8 @@ void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, i climber.nnz_samples = std::uniform_int_distribution(2000, 15000)(rng); climber.perturb_interval = std::uniform_int_distribution(50, 500)(rng); + climber.enable_infeasible_repair = (lane % 8 == 1) || (lane % 8 == 5); + climber.h_objective_weight = obj_weight_ladder[lane % 4]; //climber.seed_objective_weight = obj_weight_floor[lane % 4]; } diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 054f1b1158..b450395d72 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -283,6 +283,9 @@ struct fj_cpu_climber_t { i_t mtm_sat_samples{15}; i_t nnz_samples{50000}; i_t perturb_interval{100}; + // Enables the binary engine's infeasible-phase pair repair. Per lane, since the pair scan costs + // iterations that a well-tuned single-flip lane would rather spend elsewhere. + bool enable_infeasible_repair{false}; i_t infeasible_restart_window{300}; i_t infeasible_restart_max_streak{20}; f_t infeasible_restart_degrade_ratio{1.15}; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index cd8d247153..17cb8aaeeb 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -181,6 +181,12 @@ struct fj_bin_problem_t { std::vector objective; std::vector objective_vars; + + // Cardinality census, for the repair-pair gate. A cardinality row is an equality over binaries + // sharing one coefficient, so a variable of degree two across them can only be switched on by + // switching exactly one other off: the exchange a pair can represent. + int32_t n_exchange_vars{0}; + int32_t max_card_degree{0}; }; // Result of the width-independent eligibility scan. @@ -217,6 +223,18 @@ constexpr int32_t fj_bin_kick_restart_guard = 50; constexpr int32_t fj_bin_kick_rows = 3; constexpr int32_t fj_bin_kick_vars_per_row = 2; +// Infeasible-phase pair repair: iterations between attempts, violated rows sampled per attempt, +// and the pool size the O(pool^2) pair scan is capped to. +constexpr int32_t fj_bin_repair_interval = 20; +constexpr int32_t fj_bin_repair_max_rows = 4; +constexpr int32_t fj_bin_repair_max_vars = 12; + +// Structure the pair repair needs before it is worth running: enough variables that are shared by +// exactly two cardinality rows, and no variable shared by so many that closing the exchange takes a +// chain rather than a pair. +constexpr int32_t fj_bin_repair_min_exchange_vars = 64; +constexpr int32_t fj_bin_repair_max_card_degree = 4; + // Candidate draws per 2-opt lift search. constexpr int32_t fj_bin_2opt_candidates = 64; // prefetch distance @@ -553,6 +571,41 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, pb.objective[v] = obj[v]; if (pb.objective[v] != 0.0) pb.objective_vars.push_back(v); } + + // Every variable here is binary, so an equality row whose members share one coefficient reads as + // a cardinality constraint. Counted on the unscaled row: the row scale multiplies bound and + // coefficients alike and leaves the ratio alone. + { + std::vector card_degree(n, 0); + for (int32_t r = 0; r < m; ++r) { + const double lb = cstr_lb[r]; + const double ub = cstr_ub[r]; + if (!std::isfinite(lb) || !std::isfinite(ub) || std::fabs(lb - ub) > tol) continue; + + const int32_t begin = offsets[r]; + const int32_t end = offsets[r + 1]; + if (end - begin < 2) continue; + + const double shared = coeffs[begin]; + if (std::fabs(shared) <= tol) continue; + const double k = lb / shared; + if (k < 1.0 - tol || std::fabs(k - std::round(k)) > tol) continue; + + bool uniform = true; + for (int32_t p = begin; p < end && uniform; ++p) { + const double a = coeffs[p]; + uniform = std::fabs(a - shared) <= tol * std::max(1.0, std::fabs(shared)); + } + if (!uniform) continue; + + for (int32_t p = begin; p < end; ++p) + card_degree[variables[p]]++; + } + for (int32_t v = 0; v < n; ++v) { + if (card_degree[v] == 2) ++pb.n_exchange_vars; + if (card_degree[v] > pb.max_card_degree) pb.max_card_degree = card_degree[v]; + } + } return true; } @@ -649,6 +702,8 @@ struct fj_bin_engine_t { int32_t perturb_interval{100}; int32_t mtm_viol_samples{25}; int32_t mtm_sat_samples{15}; + bool enable_infeasible_repair{false}; + int32_t last_repair_iter{0}; int32_t infeasible_restart_window{300}; int32_t infeasible_restart_max_streak{20}; double infeasible_restart_degrade_ratio{1.15}; @@ -1274,6 +1329,92 @@ struct fj_bin_engine_t { return true; } + // Net change in the violated-row count from flipping both variables. Positive is an improvement. + // Both reverse ranges are row-ascending, so shared rows are counted once with their joint delta. + int32_t paired_flip_violation_delta(int32_t var1, + int8_t delta1, + int32_t var2, + int8_t delta2) const + { + int32_t i = pb.reverse_offsets[var1], ie = pb.reverse_offsets[var1 + 1]; + int32_t j = pb.reverse_offsets[var2], je = pb.reverse_offsets[var2 + 1]; + int32_t net = 0; + + while (i < ie || j < je) { + const int32_t r1 = i < ie ? pb.reverse_constraints[i] : INT32_MAX; + const int32_t r2 = j < je ? pb.reverse_constraints[j] : INT32_MAX; + const int32_t r = r1 < r2 ? r1 : r2; + + int32_t change = 0; + if (r1 == r) change += (int32_t)pb.reverse_coefficients[i++] * delta1; + if (r2 == r) change += (int32_t)pb.reverse_coefficients[j++] * delta2; + + const bool was_violated = row_slack[r] < 0; + const bool now_violated = row_slack[r] - change < 0; + if (was_violated && !now_violated) + ++net; + else if (!was_violated && now_violated) + --net; + } + return net; + } + + // Draws a few violated rows and searches their members for a joint flip that strictly reduces the + // violated-row count. The single-flip path cannot see these: each half may be neutral or worsening + // on its own. Rate-limited by the caller because the pair scan is quadratic in the pool. + std::pair find_infeasible_pair_repair() + { + const std::pair none{-1, -1}; + if (violated_list.empty()) return none; + + sample_buf.clear(); + const int32_t n_viol = (int32_t)violated_list.size(); + const int32_t n_rows = n_viol < fj_bin_repair_max_rows ? n_viol : fj_bin_repair_max_rows; + for (int32_t t = 0; t < n_rows; ++t) + sample_buf.push_back(violated_list[rng.next_u32() % (uint32_t)n_viol]); + + int32_t pool[fj_bin_repair_max_vars]; + int32_t n_pool = 0; + for (int32_t r : sample_buf) { + const int32_t begin = pb.offsets[r]; + const int32_t width = pb.offsets[r + 1] - begin; + if (width == 0) continue; + + // A random cyclic start rather than the CSR prefix. At a repeated local minimum the prefix + // makes the neighbourhood deterministic and leaves the tail of a wide covering row permanently + // invisible, at the same pool size and cost. + const int32_t start = (int32_t)(rng.next_u32() % (uint32_t)width); + for (int32_t q = 0; q < width && n_pool < fj_bin_repair_max_vars; ++q) { + const int32_t v = pb.variables[begin + (start + q) % width]; + bool dup = false; + for (int32_t p = 0; p < n_pool && !dup; ++p) + dup = pool[p] == v; + if (!dup) pool[n_pool++] = v; + } + } + + std::pair best_pair = none; + int32_t best_net = 0; + for (int32_t a = 0; a < n_pool; ++a) { + const int32_t v1 = pool[a]; + if (tabu_blocked(v1, false)) continue; + const int8_t delta1 = (int8_t)(1 - 2 * assign[v1]); + + for (int32_t b = a + 1; b < n_pool; ++b) { + const int32_t v2 = pool[b]; + if (tabu_blocked(v2, false)) continue; + const int8_t delta2 = (int8_t)(1 - 2 * assign[v2]); + const int32_t net = paired_flip_violation_delta(v1, delta1, v2, delta2); + if (net > best_net) { + best_net = net; + best_pair = {v1, v2}; + } + } + } + cuopt_assert(best_pair.first < 0 || best_net > 0, "accepted a repair that gains no row"); + return best_pair; + } + std::pair, int64_t> find_lift_2opt_move() { cuopt_assert(violated_list.empty(), "lift moves require a feasible incumbent"); @@ -1439,9 +1580,13 @@ struct fj_bin_engine_t { tabu_tenure_min = params.tabu_tenure_min; tabu_tenure_max = params.tabu_tenure_max; breakthrough_margin = params.breakthrough_move_epsilon; - perturb_interval = climber.perturb_interval; - mtm_viol_samples = climber.mtm_viol_samples; - mtm_sat_samples = climber.mtm_sat_samples; + perturb_interval = climber.perturb_interval; + mtm_viol_samples = climber.mtm_viol_samples; + mtm_sat_samples = climber.mtm_sat_samples; + enable_infeasible_repair = climber.enable_infeasible_repair && + pb.n_exchange_vars >= fj_bin_repair_min_exchange_vars && + pb.max_card_degree <= fj_bin_repair_max_card_degree; + last_repair_iter = 0; infeasible_restart_window = climber.infeasible_restart_window; infeasible_restart_max_streak = climber.infeasible_restart_max_streak; @@ -1563,20 +1708,36 @@ struct fj_bin_engine_t { } else if (score > 0 && move_var >= 0 && !perturb_now) { apply_move(move_var, (int8_t)(1 - 2 * assign[move_var]), climber); } else { - update_weights(); - const bool kick_ready = !violated_list.empty() && - iters_since_infeasible_improve >= fj_bin_kick_after && - iters - last_kick_iter >= fj_bin_kick_cooldown && - iters - last_restart_iter >= fj_bin_kick_restart_guard; - if (kick_ready) { - infeasible_region_kick(); - last_kick_iter = iters; - } else if (perturb_now) { - perturb(); + // A pair that reduces the violated count takes precedence over reweighting: the weights + // exist to escape a minimum no move can improve, and this found one that can. + bool repaired = false; + if (enable_infeasible_repair && !violated_list.empty() && + iters - last_repair_iter >= fj_bin_repair_interval) { + last_repair_iter = iters; + const auto repair_pair = find_infeasible_pair_repair(); + if (repair_pair.first >= 0) { + apply_move(repair_pair.first, (int8_t)(1 - 2 * assign[repair_pair.first]), climber); + apply_move(repair_pair.second, (int8_t)(1 - 2 * assign[repair_pair.second]), climber); + repaired = true; + } + } + + if (!repaired) { + update_weights(); + const bool kick_ready = !violated_list.empty() && + iters_since_infeasible_improve >= fj_bin_kick_after && + iters - last_kick_iter >= fj_bin_kick_cooldown && + iters - last_restart_iter >= fj_bin_kick_restart_guard; + if (kick_ready) { + infeasible_region_kick(); + last_kick_iter = iters; + } else if (perturb_now) { + perturb(); + } + std::tie(move_var, score) = find_move_violated(1, true); + const int32_t v = move_var >= 0 ? move_var : 0; + apply_move(v, (int8_t)(1 - 2 * assign[v]), climber); } - std::tie(move_var, score) = find_move_violated(1, true); - const int32_t v = move_var >= 0 ? move_var : 0; - apply_move(v, (int8_t)(1 - 2 * assign[v]), climber); } if (iters % climber.log_interval == 0) { From dd70c3258a412109c2f4d5364b2becaf2c3d6575 Mon Sep 17 00:00:00 2001 From: yboucher Date: Sun, 23 Aug 2026 11:35:38 -0700 Subject: [PATCH 67/78] dualsimplex LP seed, 2s --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 125 +++++++++++++++++- .../feasibility_jump/fj_cpu.cuh | 3 + 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 730f58dee4..bc604ad902 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -9,6 +9,9 @@ #include #include +#include +#include +#include #include "feasibility_jump.cuh" #include "feasibility_jump_impl_common.cuh" @@ -36,6 +39,7 @@ #include #include #include +#include #include #include @@ -2368,16 +2372,126 @@ std::unique_ptr> fj_t::create_cpu_climber( constexpr int32_t fj_nnz_per_refresh_stretch = 100000; constexpr int32_t fj_max_refresh_stretch = 8; +// Above this a short LP spends more time moving the matrix than it can pay back as a seed, and the +// wall budget the LP is allowed out of the lane's own. +constexpr int64_t fj_lp_seed_nnz_limit = 8'000'000; +constexpr double fj_lp_seed_budget_s = 2; + +// Rounds the LP relaxation into this lane's start point. Solved by dual simplex on the lane's own +// thread rather than during portfolio construction, so the other seven start searching immediately. +template +static void apply_lp_rounded_seed(fj_cpu_climber_t& fj_cpu) +{ + if (!fj_cpu.use_lp_seed || fj_cpu.pb_ptr == nullptr) return; + if (fj_cpu.view.pb.nnz > fj_lp_seed_nnz_limit) return; + + simplex::user_problem_t relaxation(fj_cpu.pb_ptr->handle_ptr); + fj_cpu.pb_ptr->get_host_user_problem(relaxation); + + simplex_solver_settings_t lp_settings; + lp_settings.relaxation = true; + lp_settings.time_limit = fj_lp_seed_budget_s; + lp_settings.log.log = false; + + std::vector relaxed; + simplex::lp_status_t lp_status = simplex::lp_status_t::UNSET; + double lp_seconds = 0; + + // solve_linear_program_advanced rather than simplex::solve, whose collapsed int return cannot + // separate a limit -- which leaves a usable vertex behind -- from infeasibility, which does not. + // Guarded on f_t because dual simplex is only instantiated for double. + if constexpr (std::is_same_v) { + const f_t lp_start = tic(); + lp_problem_t converted(relaxation.handle_ptr, + relaxation.num_rows, + relaxation.num_cols, + relaxation.A.col_start[relaxation.A.n]); + std::vector new_slacks; + simplex::dualize_info_t dualize_info; + simplex::convert_user_problem(relaxation, lp_settings, converted, new_slacks, dualize_info); + + simplex::lp_solution_t lp_solution(converted.num_rows, converted.num_cols); + std::vector vstatus; + std::vector edge_norms; + lp_status = simplex::solve_linear_program_advanced( + converted, lp_start, lp_settings, lp_solution, vstatus, edge_norms); + relaxed = lp_solution.x; + lp_seconds = toc(lp_start); + } + + // A vertex reached at a limit is dual feasible and still worth rounding. The remaining + // terminations leave nothing to round. + const bool usable = lp_status == simplex::lp_status_t::OPTIMAL || + lp_status == simplex::lp_status_t::TIME_LIMIT || + lp_status == simplex::lp_status_t::ITERATION_LIMIT || + lp_status == simplex::lp_status_t::CONCURRENT_LIMIT || + lp_status == simplex::lp_status_t::WORK_LIMIT; + CUOPT_LOG_DEBUG("%sCPUFJ LP seed: %s after %.3fs of %.3fs%s", + fj_cpu.log_prefix.c_str(), + simplex::lp_status_to_string(lp_status).c_str(), + lp_seconds, + fj_lp_seed_budget_s, + usable ? "" : ", discarded"); + if (!usable) return; + + const i_t n_variables = fj_cpu.view.pb.n_variables; + // convert_user_problem appends slacks, so the model's own variables are the leading columns. + cuopt_assert((i_t)relaxed.size() >= n_variables, "dual simplex returned too few columns"); + + std::vector candidate(n_variables); + for (i_t var = 0; var < n_variables; ++var) { + cuopt_assert(isfinite(relaxed[var]), "dual simplex returned a non-finite value"); + const auto bounds = fj_cpu.h_var_bounds[var].get(); + const f_t lower = get_lower(bounds); + const f_t upper = get_upper(bounds); + f_t value = std::clamp(relaxed[var], lower, upper); + if (is_integer_var(fj_cpu, var)) { + value = round(value); + // Rounding can leave the bounds, and a variable with no integral value inside them cannot be + // seeded at all without breaking the engine's integrality invariant. + if (value < lower || value > upper) return; + } + candidate[var] = value; + } + + fj_cpu.h_assignment = candidate; + fj_cpu.h_best_assignment = candidate; + recompute_lhs(fj_cpu); + + // The rounded point can already be integral-feasible. It never passed through apply_move, so the + // incumbent is recorded here through the same contract that path uses. + if (fj_cpu.violated_constraints.empty() && check_variable_feasibility(fj_cpu)) { + fj_cpu.h_best_objective = + fj_cpu.h_incumbent_objective - fj_cpu.settings.parameters.breakthrough_move_epsilon; + fj_cpu.feasible_found = true; + CUOPT_LOG_DEBUG("%sCPUFJ new incumbent: objective %.17g", + fj_cpu.log_prefix.c_str(), + fj_cpu.h_best_objective); + if (fj_cpu.improvement_callback) { + fj_cpu.improvement_callback(fj_cpu.h_incumbent_objective, + fj_cpu.h_assignment, + fj_cpu.work_units_elapsed.load(std::memory_order_acquire)); + } + if (fj_cpu.shared_incumbent) { + fj_cpu.shared_incumbent->publish(fj_cpu.h_incumbent_objective, fj_cpu.h_assignment); + } + } +} + template void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double work_unit_limit) { + const auto solve_start = std::chrono::high_resolution_clock::now(); // problem fits the binary fastpath shape? run it (engine is solve-local) if (try_cpufj_binary_solve(*fj_cpu, in_time_limit, work_unit_limit)) return; - i_t local_mins = 0; - auto loop_start = std::chrono::high_resolution_clock::now(); + apply_lp_rounded_seed(*fj_cpu); + + i_t local_mins = 0; + // The LP comes out of this lane's own budget; every other lane's clock starts where it did. + auto loop_start = fj_cpu->use_lp_seed ? solve_start : std::chrono::high_resolution_clock::now(); auto time_limit = std::chrono::milliseconds(static_cast(std::floor(in_time_limit * 1000.0))); - auto loop_time_start = std::chrono::high_resolution_clock::now(); + auto loop_time_start = loop_start; fj_cpu->rng.seed(fj_cpu->settings.seed); @@ -3623,14 +3737,15 @@ void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, i const f_t obj_weight_ladder[4] = {0, 4, 32, 0}; const f_t obj_weight_floor[4] = {1, 4, 32, 1}; - // One structural start per lane; lanes 0 and 4 keep the shared anchor. + // One structural start per lane; lanes 0 and 4 keep the shared anchor. Lane 7 also keeps it here, + // because its replacement is an LP solved inside that lane's own task rather than in setup. + climber.use_lp_seed = lane % 8 == 7; switch (lane % 8) { case 1: apply_lock_weighted_seed(climber); break; case 2: apply_aggressive_constraint_seed(climber); break; case 3: apply_greedy_covering_seed(climber); break; case 5: apply_bipartite_matching_seed(climber); break; case 6: apply_objective_corner_seed(climber); break; - case 7: apply_lower_bound_seed(climber); break; default: break; } diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index b450395d72..61d1bfb5e4 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -283,6 +283,9 @@ struct fj_cpu_climber_t { i_t mtm_sat_samples{15}; i_t nnz_samples{50000}; i_t perturb_interval{100}; + // One lane replaces its start with a rounded LP relaxation, solved inside that lane's own task so + // portfolio construction does not wait on an LP. + bool use_lp_seed{false}; // Enables the binary engine's infeasible-phase pair repair. Per lane, since the pair scan costs // iterations that a well-tuned single-flip lane would rather spend elsewhere. bool enable_infeasible_repair{false}; From e885c1161e4f60ec9c0c40c592aa86a2f7735839 Mon Sep 17 00:00:00 2001 From: yboucher Date: Sun, 23 Aug 2026 12:41:39 -0700 Subject: [PATCH 68/78] some optimization work --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 120 ++++++++++++------ .../feasibility_jump/fj_cpu.cuh | 3 + .../feasibility_jump/fj_cpu_binary.cu | 7 +- .../feasibility_jump/fj_cpu_binary.cuh | 7 + .../fj_cpu_binary_kernels.cpp | 31 +++++ 5 files changed, 125 insertions(+), 43 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index bc604ad902..3e89277a7f 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1075,41 +1075,72 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, fj_cpu.n_variable_updates_window++; fj_cpu.unique_vars_accessed_window.insert(var_idx); + const size_t nnz_touched = (size_t)(offset_end - offset_begin); + fj_cpu.h_reverse_constraints.byte_loads += nnz_touched * sizeof(i_t); + fj_cpu.h_reverse_coefficients.byte_loads += nnz_touched * sizeof(f_t); + fj_cpu.cached_cstr_bounds.byte_loads += nnz_touched * sizeof(std::pair); + fj_cpu.h_lhs.byte_loads += nnz_touched * sizeof(f_t); + fj_cpu.h_lhs.byte_stores += nnz_touched * sizeof(f_t); + fj_cpu.h_lhs_sumcomp.byte_loads += nnz_touched * sizeof(f_t); + fj_cpu.h_lhs_sumcomp.byte_stores += nnz_touched * sizeof(f_t); + + const i_t* const rev_cstr = fj_cpu.view.pb.reverse_constraints.data(); + const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); + const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); + f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); + f_t* const row_sumcomp = fj_cpu.view.incumbent_lhs_sumcomp.data(); + for (auto i = offset_begin; i < offset_end; i++) { cuopt_assert(i < (i_t)fj_cpu.h_reverse_constraints.size(), ""); - auto [c_lb, c_ub] = fj_cpu.cached_cstr_bounds[i].get(); + const auto [c_lb, c_ub] = cstr_bounds[i]; - auto cstr_idx = fj_cpu.h_reverse_constraints[i]; - auto cstr_coeff = fj_cpu.h_reverse_coefficients[i]; + const i_t cstr_idx = rev_cstr[i]; + const f_t cstr_coeff = rev_coeff[i]; - f_t old_lhs = fj_cpu.h_lhs[cstr_idx]; + const f_t old_lhs = row_lhs[cstr_idx]; // Kahan compensated summation - f_t y = cstr_coeff * delta - fj_cpu.h_lhs_sumcomp[cstr_idx]; - f_t t = old_lhs + y; - fj_cpu.h_lhs_sumcomp[cstr_idx] = (t - old_lhs) - y; - fj_cpu.h_lhs[cstr_idx] = t; - f_t new_lhs = fj_cpu.h_lhs[cstr_idx]; - f_t old_cost = fj_cpu.view.excess_score(cstr_idx, old_lhs, c_lb, c_ub); - f_t new_cost = fj_cpu.view.excess_score(cstr_idx, new_lhs, c_lb, c_ub); - f_t cstr_tolerance = fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + const f_t y = cstr_coeff * delta - row_sumcomp[cstr_idx]; + const f_t t = old_lhs + y; + const f_t new_sumcomp = (t - old_lhs) - y; + row_sumcomp[cstr_idx] = new_sumcomp; + row_lhs[cstr_idx] = t; + + const f_t old_cost = fj_cpu.view.excess_score(cstr_idx, old_lhs, c_lb, c_ub); + const f_t new_cost = fj_cpu.view.excess_score(cstr_idx, t, c_lb, c_ub); + const f_t cstr_tolerance = fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub); // trigger early lhs recomputation if the sumcomp term gets too large // to avoid large numerical errors - if (fabs(fj_cpu.h_lhs_sumcomp[cstr_idx]) > BIGVAL_THRESHOLD) - fj_cpu.trigger_early_lhs_recomputation = true; + if (fabs(new_sumcomp) > BIGVAL_THRESHOLD) fj_cpu.trigger_early_lhs_recomputation = true; + + const bool was_violated = fj_cpu.violated_constraints.contains(cstr_idx); + const bool now_violated = new_cost < -cstr_tolerance; - if (new_cost < -cstr_tolerance && !fj_cpu.violated_constraints.contains(cstr_idx)) { + // total_violations sums the excess over the violated set alone, so a row crossing the boundary + // contributes its whole cost rather than a difference. Kahan compensated, as h_lhs is: this is + // now the only place the total is maintained between refreshes. + const f_t viol_delta = + (now_violated ? new_cost : f_t{0}) - (was_violated ? old_cost : f_t{0}); + if (viol_delta != f_t{0}) { + const f_t viol_old = fj_cpu.total_violations; + const f_t viol_y = viol_delta - fj_cpu.total_violations_sumcomp; + const f_t viol_t = viol_old + viol_y; + fj_cpu.total_violations_sumcomp = (viol_t - viol_old) - viol_y; + fj_cpu.total_violations = viol_t; + } + + if (now_violated && !was_violated) { fj_cpu.violated_constraints.insert(cstr_idx); cuopt_assert(fj_cpu.satisfied_constraints.contains(cstr_idx), ""); fj_cpu.satisfied_constraints.remove(cstr_idx); - } else if (!(new_cost < -cstr_tolerance) && fj_cpu.violated_constraints.contains(cstr_idx)) { + } else if (!now_violated && was_violated) { cuopt_assert(!fj_cpu.satisfied_constraints.contains(cstr_idx), ""); fj_cpu.violated_constraints.remove(cstr_idx); fj_cpu.satisfied_constraints.insert(cstr_idx); } cuopt_assert(isfinite(delta), "delta should be finite"); - cuopt_assert(isfinite(fj_cpu.h_lhs[cstr_idx]), "assignment should be finite"); + cuopt_assert(isfinite(t), "assignment should be finite"); // Invalidate related cached move scores fj_cpu.h_cstr_version[cstr_idx]++; @@ -1413,7 +1444,8 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) fj_cpu.violated_constraints.clear(); fj_cpu.satisfied_constraints.clear(); - fj_cpu.total_violations = 0; + fj_cpu.total_violations = 0; + fj_cpu.total_violations_sumcomp = 0; for (i_t cstr_idx = 0; cstr_idx < fj_cpu.view.pb.n_constraints; ++cstr_idx) { auto [offset_begin, offset_end] = range_for_constraint(fj_cpu, cstr_idx); auto c_lb = fj_cpu.h_cstr_lb[cstr_idx]; @@ -1596,20 +1628,37 @@ static thrust::tuple find_lift_move( if (delta * obj_coeff >= 0) continue; auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); - bool breaks_a_row = false; + + const i_t* const rev_cstr = fj_cpu.view.pb.reverse_constraints.data(); + const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); + const f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); + const f_t* const row_sumcomp = fj_cpu.view.incumbent_lhs_sumcomp.data(); + const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); + + bool breaks_a_row = false; + i_t scanned = 0; for (i_t j = offset_begin; j < offset_end; ++j) { - auto [c_lb, c_ub] = fj_cpu.cached_cstr_bounds[j].get(); - const i_t cstr_idx = fj_cpu.h_reverse_constraints[j]; - const f_t cstr_coeff = fj_cpu.h_reverse_coefficients[j]; - const f_t lhs = fj_cpu.h_lhs[cstr_idx]; - const f_t sumcomp = fj_cpu.h_lhs_sumcomp[cstr_idx]; - const f_t new_lhs = lhs + (cstr_coeff * delta - sumcomp); + ++scanned; + const auto [c_lb, c_ub] = cstr_bounds[j]; + const i_t cstr_idx = rev_cstr[j]; + const f_t cstr_coeff = rev_coeff[j]; + const f_t lhs = row_lhs[cstr_idx]; + const f_t sumcomp = row_sumcomp[cstr_idx]; + const f_t new_lhs = lhs + (cstr_coeff * delta - sumcomp); if (fj_cpu.view.excess_score(cstr_idx, new_lhs, c_lb, c_ub) < -fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub)) { breaks_a_row = true; break; } } + + const size_t nnz_scanned = (size_t)scanned; + fj_cpu.h_reverse_constraints.byte_loads += nnz_scanned * sizeof(i_t); + fj_cpu.h_reverse_coefficients.byte_loads += nnz_scanned * sizeof(f_t); + fj_cpu.cached_cstr_bounds.byte_loads += nnz_scanned * sizeof(std::pair); + fj_cpu.h_lhs.byte_loads += nnz_scanned * sizeof(f_t); + fj_cpu.h_lhs_sumcomp.byte_loads += nnz_scanned * sizeof(f_t); + if (breaks_a_row) continue; } else { f_t lfd_lb = get_lower(fj_cpu.h_var_bounds[var_idx].get()) - val; @@ -2156,13 +2205,14 @@ static void finalize_fj_cpu_host_initialization_from_template( fj_cpu.cached_cstr_bounds = tmpl.cached_cstr_bounds; fj_cpu.obj_magnitude = tmpl.obj_magnitude; - fj_cpu.h_lhs = tmpl.h_lhs; - fj_cpu.h_lhs_sumcomp = tmpl.h_lhs_sumcomp; - fj_cpu.violated_constraints = tmpl.violated_constraints; - fj_cpu.satisfied_constraints = tmpl.satisfied_constraints; - fj_cpu.total_violations = tmpl.total_violations; - fj_cpu.h_incumbent_objective = tmpl.h_incumbent_objective; - fj_cpu.h_objective_sumcomp = tmpl.h_objective_sumcomp; + fj_cpu.h_lhs = tmpl.h_lhs; + fj_cpu.h_lhs_sumcomp = tmpl.h_lhs_sumcomp; + fj_cpu.violated_constraints = tmpl.violated_constraints; + fj_cpu.satisfied_constraints = tmpl.satisfied_constraints; + fj_cpu.total_violations = tmpl.total_violations; + fj_cpu.total_violations_sumcomp = tmpl.total_violations_sumcomp; + fj_cpu.h_incumbent_objective = tmpl.h_incumbent_objective; + fj_cpu.h_objective_sumcomp = tmpl.h_objective_sumcomp; fj_cpu.n_binary_vars = tmpl.n_binary_vars; fj_cpu.n_integer_vars = tmpl.n_integer_vars; @@ -2620,12 +2670,6 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w ++fj_cpu->n_local_minima_window; } - // number of violated constraints is usually small (<100). recomputing from all LHSs is cheap - // and more numerically precise than just adding to the accumulator in apply_move - fj_cpu->total_violations = 0; - for (auto cstr_idx : fj_cpu->violated_constraints) { - fj_cpu->total_violations += fj_cpu->view.excess_score(cstr_idx, fj_cpu->h_lhs[cstr_idx]); - } if (fj_cpu->iterations % fj_cpu->log_interval == 0) { CUOPT_LOG_DEBUG( "%sCPUFJ iteration: %d/%d, local mins: %d, best_objective: %g, viol: %zu, obj weight %g, " diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 61d1bfb5e4..5c65f0e383 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -232,6 +232,9 @@ struct fj_cpu_climber_t { bool feasible_found{false}; bool trigger_early_lhs_recomputation{false}; f_t total_violations{0}; + // Kahan compensation for total_violations, mirroring h_lhs_sumcomp. Reset wherever the total is + // re-derived from the violated set. + f_t total_violations_sumcomp{0}; // Timing data structures std::vector find_lift_move_times; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 17cb8aaeeb..8f7ec35a4e 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -1205,11 +1205,8 @@ struct fj_bin_engine_t { // field where it can only separate variables already tied on the base. The winner's score is // then taken from full_score so the caller sees the true value. ensure_objective_base(); - const int64_t* const obj_p = obj_base_score.data(); - const int64_t* const var_p = var_score.data(); - int64_t* const comb_p = combined_score.data(); - for (int32_t v = 0; v < pb.n_variables; ++v) - comb_p[v] = var_p[v] + obj_p[v]; + int64_t* const comb_p = combined_score.data(); + fj_bin_add_scores(var_score.data(), obj_base_score.data(), pb.n_variables, comb_p); int32_t saved_var[fj_bin_tabu_t::ring_size]; int64_t saved_score[fj_bin_tabu_t::ring_size]; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh index 524ead8780..08005817aa 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh @@ -121,4 +121,11 @@ void fj_bin_argmax(const int64_t* var_score, int32_t& best_var, int64_t& best_score); +// combined[v] = var_score[v] + obj_score[v] over n variables, which is the full score once the +// objective weight is nonzero. The three arrays must not overlap. +void fj_bin_add_scores(const int64_t* var_score, + const int64_t* obj_score, + int32_t n, + int64_t* combined); + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp index f140041ac6..fd8a93ad73 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp @@ -456,6 +456,26 @@ void ArgmaxImpl(const int64_t* HWY_RESTRICT var_score, *best_score = bs; } +// combined[v] = var_score[v] + obj_score[v] over all n variables. Materialized rather than fused +// into the argmax because block_tabu writes sentinels into the result and restores them afterwards, +// so the array has to outlive the scan. None of the three has SIMD padding, hence the scalar tail. +void AddScoresImpl(const int64_t* HWY_RESTRICT var_score, + const int64_t* HWY_RESTRICT obj_score, + int32_t n, + int64_t* HWY_RESTRICT combined) +{ + const hn::ScalableTag d; + const int32_t step = (int32_t)hn::Lanes(d); + const int32_t nblk = n - (n % step); + + for (int32_t v = 0; v < nblk; v += step) { + hn::StoreU(hn::Add(hn::LoadU(d, var_score + v), hn::LoadU(d, obj_score + v)), d, combined + v); + } + for (int32_t v = nblk; v < n; ++v) { + combined[v] = var_score[v] + obj_score[v]; + } +} + } // namespace HWY_NAMESPACE } // namespace cuopt::mathematical_optimization::mip HWY_AFTER_NAMESPACE(); @@ -472,6 +492,7 @@ HWY_EXPORT_T(PatchRowI16, PatchRowDispatchImpl); HWY_EXPORT_T(WalkRowsI8, WalkRowsImpl); HWY_EXPORT_T(WalkRowsI16, WalkRowsImpl); HWY_EXPORT(ArgmaxImpl); +HWY_EXPORT(AddScoresImpl); // HWY_DYNAMIC_DISPATCH resolves the target on every call, and the hwy::GetChosenTarget() call it // expands to is a real out-of-line call: it clobbers the argument registers, so the compiler spills @@ -527,6 +548,8 @@ static fj_bin_walk_fn_t fj_bin_walk_fn(int8_t) { return fj_bin_walk_i8; static fj_bin_walk_fn_t fj_bin_walk_fn(int16_t) { return fj_bin_walk_i16; } static const auto fj_bin_argmax_fn = (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(ArgmaxImpl)); +static const auto fj_bin_add_scores_fn = + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(AddScoresImpl)); template int32_t fj_bin_walk_rows(int32_t* row_slack, @@ -600,5 +623,13 @@ void fj_bin_argmax(const int64_t* var_score, fj_bin_argmax_fn(var_score, n, tile, &best_var, &best_score); } +void fj_bin_add_scores(const int64_t* var_score, + const int64_t* obj_score, + int32_t n, + int64_t* combined) +{ + fj_bin_add_scores_fn(var_score, obj_score, n, combined); +} + } // namespace cuopt::mathematical_optimization::mip #endif // HWY_ONCE From 0795c61f2aa8d80d28e8c3eaa26f594749b60e0e Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 24 Aug 2026 07:06:24 -0700 Subject: [PATCH 69/78] some optimization efforts --- .../linear_programming/cuopt/run_cpufj.cu | 124 ++++++++ .../mip_heuristics/feasibility_jump/fj_cpu.cu | 293 +++++++++++++----- .../feasibility_jump/fj_cpu.cuh | 50 ++- 3 files changed, 380 insertions(+), 87 deletions(-) diff --git a/benchmarks/linear_programming/cuopt/run_cpufj.cu b/benchmarks/linear_programming/cuopt/run_cpufj.cu index 1ab13ca28b..67dafa847b 100644 --- a/benchmarks/linear_programming/cuopt/run_cpufj.cu +++ b/benchmarks/linear_programming/cuopt/run_cpufj.cu @@ -147,6 +147,68 @@ int main(int argc, char** argv) problem.n_constraints, problem.nnz); + // Taken from the host-side parse, so it is independent of everything under target_code. + { + const auto& col_indices = mps_data_model.get_constraint_matrix_indices(); + const auto& row_lb = mps_data_model.get_constraint_lower_bounds(); + const auto& row_ub = mps_data_model.get_constraint_upper_bounds(); + const int64_t nnz = (int64_t)col_indices.size(); + + const i_t n_cols = mps_data_model.get_n_variables(); + std::vector degree(n_cols, 0); + for (i_t index : col_indices) { + if (index >= 0 && index < n_cols) ++degree[index]; + } + std::sort(degree.begin(), degree.end()); + + const i_t max_degree = degree.empty() ? 0 : degree.back(); + auto quantile = [&](double q) { + return degree.empty() + ? 0 + : degree[std::min(degree.size() - 1, (size_t)(q * degree.size()))]; + }; + int64_t top10 = 0; + for (size_t k = 0; k < 10 && k < degree.size(); ++k) + top10 += degree[degree.size() - 1 - k]; + const double mean_degree = n_cols > 0 ? (double)nnz / n_cols : 0.0; + std::printf("census cols: n=%d degree max=%d p99=%d p90=%d median=%d mean=%.1f" + " widest=%.1f%% top10=%.1f%% of nnz hub=%.0fx mean\n", + n_cols, + max_degree, + quantile(0.99), + quantile(0.90), + quantile(0.50), + mean_degree, + nnz > 0 ? 100.0 * max_degree / nnz : 0.0, + nnz > 0 ? 100.0 * top10 / nnz : 0.0, + mean_degree > 0 ? max_degree / mean_degree : 0.0); + + const i_t n_rows = (i_t)std::min(row_lb.size(), row_ub.size()); + i_t lb_only = 0, ub_only = 0, equality = 0, ranged = 0, free_rows = 0; + for (i_t r = 0; r < n_rows; ++r) { + const bool has_lb = std::isfinite((double)row_lb[r]); + const bool has_ub = std::isfinite((double)row_ub[r]); + if (has_lb && has_ub) { + ++(row_lb[r] == row_ub[r] ? equality : ranged); + } else if (has_lb) { + ++lb_only; + } else if (has_ub) { + ++ub_only; + } else { + ++free_rows; + } + } + std::printf("census rows: n=%d lb_only=%d ub_only=%d equality=%d ranged=%d free=%d" + " one_sided=%.1f%%\n", + n_rows, + lb_only, + ub_only, + equality, + ranged, + free_rows, + n_rows > 0 ? 100.0 * (lb_only + ub_only) / n_rows : 0.0); + } + // FROZEN -- defines t=0 for the benchmark. Everything above it (the MPS parse, // problem construction under problem/, and the name anonymisation) is outside // target_code; everything below it is editable. A marker any later would leave @@ -308,6 +370,68 @@ int main(int argc, char** argv) bks_user ? std::to_string(*bks_user).c_str() : (cuopt_bench::is_known_infeasible(path) ? "known infeasible" : "unknown")); + std::printf("\n climber | moves | apply nnz | nnz/move | bitmap elems | ratio |" + " bump/apply | bump/weight | mtm inval | cache hit%%\n"); + std::printf("---------+-----------+------------+----------+--------------+-------+" + "------------+-------------+-----------+-----------\n"); + for (int k = 0; k < n_climbers; ++k) { + const auto& c = *climbers[k]; + const int64_t bitmap = 2 * c.n_moves_applied * (int64_t)c.view.pb.n_variables; + const int64_t probes = c.hit_count + c.miss_count; + std::printf(" %7d | %9lld | %10lld | %8.1f | %12lld | %5.0f | %10lld | %11lld | %9lld |" + " %9.2f\n", + k, + (long long)c.n_moves_applied, + (long long)c.apply_move_nnz, + c.n_moves_applied > 0 ? (double)c.apply_move_nnz / c.n_moves_applied : 0.0, + (long long)bitmap, + c.apply_move_nnz > 0 ? (double)bitmap / c.apply_move_nnz : 0.0, + (long long)c.n_version_bumps_apply, + (long long)c.n_version_bumps_weights, + (long long)c.n_mtm_cache_invalidations, + probes > 0 ? 100.0 * c.hit_count / probes : 0.0); + } + + std::printf("\n climber | mtm calls | row entries | ent/call | capped ent | capped/call |" + " score calls | score nnz | nnz/score | nnz budget\n"); + std::printf("---------+-----------+-------------+----------+-------------+-------------+" + "-------------+-----------+-----------+-----------\n"); + for (int k = 0; k < n_climbers; ++k) { + const auto& c = *climbers[k]; + std::printf(" %7d | %9lld | %11lld | %8.0f | %11lld | %11.0f | %11lld | %9lld | %9.1f |" + " %10d\n", + k, + (long long)c.n_mtm_calls, + (long long)c.mtm_row_entries, + c.n_mtm_calls > 0 ? (double)c.mtm_row_entries / c.n_mtm_calls : 0.0, + (long long)c.mtm_entries_capped, + c.n_mtm_calls > 0 ? (double)c.mtm_entries_capped / c.n_mtm_calls : 0.0, + (long long)c.n_compute_score_calls, + (long long)c.compute_score_nnz, + c.n_compute_score_calls > 0 + ? (double)c.compute_score_nnz / c.n_compute_score_calls + : 0.0, + c.nnz_samples); + } + + std::printf("\n climber | refresh period | lhs total | periodic | bigval | perturb | restart |" + " epi vars | epi projections\n"); + std::printf("---------+----------------+-----------+----------+--------+---------+---------+" + "----------+----------------\n"); + for (int k = 0; k < n_climbers; ++k) { + const auto& c = *climbers[k]; + std::printf(" %7d | %14d | %9lld | %8lld | %6lld | %7lld | %7lld | %8zu | %15lld\n", + k, + c.lhs_refresh_period_used, + (long long)c.n_lhs_recompute_total, + (long long)c.n_lhs_recompute_periodic, + (long long)c.n_lhs_recompute_bigval, + (long long)c.n_lhs_recompute_perturb, + (long long)c.n_lhs_recompute_restart, + c.epigraph_vars.size(), + (long long)c.n_epigraph_projections); + } + std::printf("\nSUMMARY: %d/%d crossed (%.0f%%) wall=%.1fs total_iters=%.0f agg_iters/s=%.0f\n", crossed, n_climbers, diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 3e89277a7f..2ee9378a9f 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -104,22 +104,19 @@ thrust::tuple get_mtm_for_bound(const typename fj_t::climber } template -thrust::tuple get_mtm_for_constraint( - const typename fj_t::climber_data_t::view_t& fj, - i_t var_idx, - i_t cstr_idx, - f_t cstr_coeff, - f_t c_lb, - f_t c_ub, - const ArrayType& assignment, - const ArrayType& lhs_vector) +thrust::tuple get_mtm_for_constraint(i_t var_idx, + i_t cstr_idx, + f_t cstr_coeff, + f_t c_lb, + f_t c_ub, + const ArrayType& assignment, + const ArrayType& lhs_vector, + f_t cstr_tolerance) { f_t sign = -1; f_t delta_ij = 0; f_t slack = 0; - f_t cstr_tolerance = fj.get_corrected_tolerance(cstr_idx, c_lb, c_ub); - f_t old_val = assignment[var_idx]; // process each bound as two separate constraints @@ -150,7 +147,7 @@ thrust::tuple get_mtm_for_constraint( } template -std::pair feas_score_constraint(const typename fj_t::climber_data_t::view_t& fj, +std::pair feas_score_constraint(fj_cpu_climber_t& fj_cpu, f_t delta, i_t cstr_idx, f_t cstr_coeff, @@ -158,8 +155,10 @@ std::pair feas_score_constraint(const typename fj_t::climber f_t c_ub, f_t current_lhs, f_t left_weight, - f_t right_weight) + f_t right_weight, + f_t cstr_tolerance) { + const auto& fj = fj_cpu.view; cuopt_assert(isfinite(delta), "invalid delta"); cuopt_assert(cstr_coeff != 0 && isfinite(cstr_coeff), "invalid coefficient"); @@ -170,21 +169,15 @@ std::pair feas_score_constraint(const typename fj_t::climber cuopt_assert(isfinite(c_lb) || isfinite(c_ub), "no range"); // Independent of bound_idx. - const f_t moved_lhs = current_lhs + cstr_coeff * delta; - const f_t cstr_tolerance = fj.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + const f_t moved_lhs = current_lhs + cstr_coeff * delta; const bool old_viol = fj.excess_score(cstr_idx, current_lhs, c_lb, c_ub) < -cstr_tolerance; const bool new_viol = fj.excess_score(cstr_idx, moved_lhs, c_lb, c_ub) < -cstr_tolerance; for (i_t bound_idx = 0; bound_idx < 2; ++bound_idx) { if (!isfinite(bounds[bound_idx])) continue; - // factor to correct the lhs/rhs to turn a lb <= lhs <= ub constraint into - // two virtual leq constraints "lhs <= ub" and "-lhs <= -lb" in order to match - // the convention of the paper - - // TODO: broadcast left/right weights to a csr_offset-indexed table? local minimums - // usually occur on a rarer basis (around 50 iteratiosn to 1 local minimum) - // likely unreasonable and overkill however + // factor to correct the lhs/rhs to turn a lb <= lhs <= ub constraint into two virtual leq + // constraints "lhs <= ub" and "-lhs <= -lb", to match the convention of the paper f_t cstr_weight = bound_idx == 0 ? left_weight : right_weight; f_t sign = bound_idx == 0 ? -1 : 1; f_t rhs = bounds[bound_idx] * sign; @@ -221,12 +214,12 @@ std::pair feas_score_constraint(const typename fj_t::climber // simple improvement else if (!old_sat && !new_sat && old_lhs > new_lhs) { cuopt_assert(old_viol && new_viol, ""); - base_feas += (i_t)(cstr_weight * fj.settings->parameters.excess_improvement_weight); + base_feas += (i_t)(cstr_weight * fj_cpu.settings.parameters.excess_improvement_weight); } // simple worsening else if (!old_sat && !new_sat && old_lhs < new_lhs) { cuopt_assert(old_viol && new_viol, ""); - base_feas -= (i_t)(cstr_weight * fj.settings->parameters.excess_improvement_weight); + base_feas -= (i_t)(cstr_weight * fj_cpu.settings.parameters.excess_improvement_weight); } // robustness score bonus if this would leave some strick slack @@ -415,9 +408,9 @@ static void log_regression_features(fj_cpu_climber_t& fj_cpu, double eval_intensity = (double)fj_cpu.nnz_processed_window / 1000.0; // Cache and locality metrics - i_t cache_hits_window = fj_cpu.hit_count - fj_cpu.hit_count_window_start; - i_t cache_misses_window = fj_cpu.miss_count - fj_cpu.miss_count_window_start; - i_t total_cache_accesses = cache_hits_window + cache_misses_window; + int64_t cache_hits_window = fj_cpu.hit_count - fj_cpu.hit_count_window_start; + int64_t cache_misses_window = fj_cpu.miss_count - fj_cpu.miss_count_window_start; + int64_t total_cache_accesses = cache_hits_window + cache_misses_window; double cache_hit_rate = total_cache_accesses > 0 ? (double)cache_hits_window / total_cache_accesses : 0.0; @@ -623,18 +616,22 @@ static inline std::pair compute_score(fj_cpu_climber_t); fj_cpu.h_lhs.byte_loads += nnz_read * sizeof(f_t); fj_cpu.h_cstr_left_weights.byte_loads += nnz_read * sizeof(f_t); fj_cpu.h_cstr_right_weights.byte_loads += nnz_read * sizeof(f_t); + fj_cpu.h_cstr_tolerance.byte_loads += nnz_read * sizeof(f_t); const i_t* const rev_cstr = fj_cpu.view.pb.reverse_constraints.data(); const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); const f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); const f_t* const weight_l = fj_cpu.view.cstr_left_weights.data(); const f_t* const weight_r = fj_cpu.view.cstr_right_weights.data(); + const f_t* const row_tol = fj_cpu.h_cstr_tolerance.data(); const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); for (i_t i = offset_begin; i < offset_end; i++) { @@ -644,7 +641,7 @@ static inline std::pair compute_score(fj_cpu_climber_t(fj_cpu.view, + auto [cstr_base_feas, cstr_bonus_robust] = feas_score_constraint(fj_cpu, delta, cstr_idx, cstr_coeff, @@ -652,7 +649,8 @@ static inline std::pair compute_score(fj_cpu_climber_t(fj_cpu.view, + feas_score_constraint(fj_cpu, lhs_delta, cstr_idx, 1, @@ -740,7 +738,8 @@ static fj_staged_score_t two_opt_compute_pair_score( fj_cpu.h_cstr_ub[cstr_idx], fj_cpu.h_lhs[cstr_idx], fj_cpu.h_cstr_left_weights[cstr_idx], - fj_cpu.h_cstr_right_weights[cstr_idx]); + fj_cpu.h_cstr_right_weights[cstr_idx], + fj_cpu.h_cstr_tolerance[cstr_idx]); base_feas_sum += cstr_base_feas; bonus_robust_sum += cstr_bonus_robust; } @@ -1031,6 +1030,7 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) } // Invalidate related cached move scores + ++fj_cpu.n_version_bumps_weights; fj_cpu.h_cstr_version[cstr_idx]++; } @@ -1076,6 +1076,9 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, fj_cpu.unique_vars_accessed_window.insert(var_idx); const size_t nnz_touched = (size_t)(offset_end - offset_begin); + ++fj_cpu.n_moves_applied; + fj_cpu.apply_move_nnz += (int64_t)nnz_touched; + fj_cpu.n_version_bumps_apply += (int64_t)nnz_touched; fj_cpu.h_reverse_constraints.byte_loads += nnz_touched * sizeof(i_t); fj_cpu.h_reverse_coefficients.byte_loads += nnz_touched * sizeof(f_t); fj_cpu.cached_cstr_bounds.byte_loads += nnz_touched * sizeof(std::pair); @@ -1083,10 +1086,12 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, fj_cpu.h_lhs.byte_stores += nnz_touched * sizeof(f_t); fj_cpu.h_lhs_sumcomp.byte_loads += nnz_touched * sizeof(f_t); fj_cpu.h_lhs_sumcomp.byte_stores += nnz_touched * sizeof(f_t); + fj_cpu.h_cstr_tolerance.byte_loads += nnz_touched * sizeof(f_t); const i_t* const rev_cstr = fj_cpu.view.pb.reverse_constraints.data(); const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); + const f_t* const row_tol = fj_cpu.h_cstr_tolerance.data(); f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); f_t* const row_sumcomp = fj_cpu.view.incumbent_lhs_sumcomp.data(); @@ -1107,7 +1112,7 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, const f_t old_cost = fj_cpu.view.excess_score(cstr_idx, old_lhs, c_lb, c_ub); const f_t new_cost = fj_cpu.view.excess_score(cstr_idx, t, c_lb, c_ub); - const f_t cstr_tolerance = fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + const f_t cstr_tolerance = row_tol[cstr_idx]; // trigger early lhs recomputation if the sumcomp term gets too large // to avoid large numerical errors @@ -1204,9 +1209,46 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, // CUOPT_LOG_TRACE("CPU: tabu noinc_until: %d\n", fj_cpu.h_tabu_noinc_until[var_idx]); } - std::fill(fj_cpu.flip_move_computed.begin(), fj_cpu.flip_move_computed.end(), false); - std::fill(fj_cpu.var_bitmap.begin(), fj_cpu.var_bitmap.end(), false); - fj_cpu.iter_mtm_vars.clear(); + ++fj_cpu.flip_move_epoch; +} + +// Tightest value the rows of a certified epigraph variable imply. Satisfies all of them at once and +// leaves the objective as small as they allow, which is why it is sound from an infeasible point. +template +static f_t project_epigraph_variable(fj_cpu_climber_t& fj_cpu, i_t var_idx) +{ + cuopt_assert(fj_cpu.epigraph_push[var_idx] != 0, "variable is not a certified epigraph variable"); + const bool push_up = fj_cpu.epigraph_push[var_idx] > 0; + const f_t current = fj_cpu.h_assignment[var_idx]; + const auto bounds = fj_cpu.h_var_bounds[var_idx].get(); + f_t target = push_up ? get_lower(bounds) : get_upper(bounds); + + auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); + const size_t nnz_read = (size_t)(offset_end - offset_begin); + fj_cpu.h_reverse_constraints.byte_loads += nnz_read * sizeof(i_t); + fj_cpu.h_reverse_coefficients.byte_loads += nnz_read * sizeof(f_t); + fj_cpu.cached_cstr_bounds.byte_loads += nnz_read * sizeof(std::pair); + fj_cpu.h_lhs.byte_loads += nnz_read * sizeof(f_t); + + const i_t* const rev_cstr = fj_cpu.view.pb.reverse_constraints.data(); + const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); + const f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); + const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); + + for (i_t p = offset_begin; p < offset_end; ++p) { + const f_t coeff = rev_coeff[p]; + if (coeff == f_t{0}) continue; + const auto [c_lb, c_ub] = cstr_bounds[p]; + const f_t rest = row_lhs[rev_cstr[p]] - coeff * current; + const f_t bound = ((coeff > f_t{0}) == push_up) ? c_lb : c_ub; + const f_t implied = (bound - rest) / coeff; + if (!isfinite(implied)) continue; + target = push_up ? max(target, implied) : min(target, implied); + } + + target = std::min(std::max(target, get_lower(bounds)), get_upper(bounds)); + cuopt_assert(isfinite(target), "epigraph projection is not finite"); + return target; } template @@ -1220,34 +1262,43 @@ static thrust::tuple find_mtm_move( fj_move_t best_move = fj_move_t{-1, 0}; fj_staged_score_t best_score = fj_staged_score_t::invalid(); - // collect all the variables that are involved in the target constraints + ++fj_cpu.n_mtm_calls; + + // Each row contributes at most its share of the sampling budget. The gate below sits inside the + // walk, so an uncapped wide row is walked in full whatever the budget says. + const i_t per_row_cap = + std::max(1, fj_cpu.nnz_samples / std::max(1, (i_t)target_cstrs.size())); + + i_t entries = 0; for (size_t cstr_idx : target_cstrs) { auto [offset_begin, offset_end] = range_for_constraint(fj_cpu, cstr_idx); - for (auto i = offset_begin; i < offset_end; i++) { - i_t var_idx = fj_cpu.h_variables[i]; - if (fj_cpu.var_bitmap[var_idx]) continue; - fj_cpu.iter_mtm_vars.push_back(var_idx); - fj_cpu.var_bitmap[var_idx] = true; - } - } - // estimate the amount of nnzs to consider - i_t nnz_sum = 0; - for (auto var_idx : fj_cpu.iter_mtm_vars) { - auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); - nnz_sum += offset_end - offset_begin; + const i_t width = offset_end - offset_begin; + entries += std::min(width, per_row_cap); + fj_cpu.mtm_entries_capped += (int64_t)std::max(0, width - per_row_cap); } + fj_cpu.mtm_row_entries += (int64_t)entries; + + // The exact sum over the candidate variables costs one random offset read each to set a single + // sampling rate. The mean reverse degree estimates it in constant time. + const f_t mean_reverse_degree = + (f_t)fj_cpu.h_coefficients.size() / (f_t)std::max(1, fj_cpu.view.pb.n_variables); + const f_t nnz_sum = (f_t)entries * mean_reverse_degree; f_t nnz_pick_probability = 1; - if (nnz_sum > fj_cpu.nnz_samples) nnz_pick_probability = (f_t)fj_cpu.nnz_samples / nnz_sum; + if (nnz_sum > (f_t)fj_cpu.nnz_samples) nnz_pick_probability = (f_t)fj_cpu.nnz_samples / nnz_sum; for (size_t cstr_idx : target_cstrs) { - auto c_lb = fj_cpu.h_cstr_lb[cstr_idx]; - auto c_ub = fj_cpu.h_cstr_ub[cstr_idx]; - f_t cstr_tol = fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + f_t cstr_tol = fj_cpu.h_cstr_tolerance[cstr_idx]; cuopt_assert(cstr_idx < fj_cpu.h_cstr_lb.size(), "cstr_idx is out of bounds"); auto [offset_begin, offset_end] = range_for_constraint(fj_cpu, cstr_idx); - for (auto i = offset_begin; i < offset_end; i++) { + const i_t width = offset_end - offset_begin; + const i_t visit = std::min(width, per_row_cap); + const i_t start = visit == width + ? offset_begin + : offset_begin + (i_t)(rng.next_u32() % (uint32_t)width); + for (i_t q = 0, i = start; q < visit; + ++q, i = (i + 1 == offset_end ? offset_begin : i + 1)) { // early cached check cuopt_assert(fj_cpu.cached_mtm_moves_version[i] <= fj_cpu.h_cstr_version[cstr_idx], "cached move newer than its constraint"); @@ -1280,23 +1331,23 @@ static thrust::tuple find_mtm_move( // Special case for binary variables if (fj_cpu.h_is_binary_variable[var_idx]) { - if (fj_cpu.flip_move_computed[var_idx]) continue; - fj_cpu.flip_move_computed[var_idx] = true; - new_val = 1 - val; + if (fj_cpu.flip_move_stamp[var_idx] == fj_cpu.flip_move_epoch) continue; + fj_cpu.flip_move_stamp[var_idx] = fj_cpu.flip_move_epoch; + new_val = 1 - val; } else { auto cstr_coeff = fj_cpu.h_coefficients[i]; f_t c_lb = fj_cpu.h_cstr_lb[cstr_idx]; f_t c_ub = fj_cpu.h_cstr_ub[cstr_idx]; auto [delta, sign, slack, cstr_tolerance] = - get_mtm_for_constraint(fj_cpu.view, - var_idx, + get_mtm_for_constraint(var_idx, cstr_idx, cstr_coeff, c_lb, c_ub, fj_cpu.h_assignment, - fj_cpu.h_lhs); + fj_cpu.h_lhs, + cstr_tol); if (is_integer_var(fj_cpu, var_idx)) { new_val = cstr_coeff * sign > 0 ? floor(val + delta + fj_cpu.view.pb.tolerances.integrality_tolerance) @@ -1434,6 +1485,7 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) { CPUFJ_NVTX_RANGE("CPUFJ::recompute_lhs"); cuopt_assert(fj_cpu.h_lhs.size() == fj_cpu.view.pb.n_constraints, "h_lhs size mismatch"); + ++fj_cpu.n_lhs_recompute_total; // clamp to var bounds - defensive; apply_move should already have clamped appropriately for (i_t var_idx = 0; var_idx < fj_cpu.view.pb.n_variables; ++var_idx) { @@ -1458,7 +1510,7 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) fj_kahan_babushka_neumaier_sum(delta_it + offset_begin, delta_it + offset_end); fj_cpu.h_lhs_sumcomp[cstr_idx] = 0; - f_t cstr_tolerance = fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + f_t cstr_tolerance = fj_cpu.h_cstr_tolerance[cstr_idx]; f_t new_cost = fj_cpu.view.excess_score(cstr_idx, fj_cpu.h_lhs[cstr_idx]); if (new_cost < -cstr_tolerance) { fj_cpu.violated_constraints.insert(cstr_idx); @@ -1513,8 +1565,7 @@ static bool paired_flip_keeps_feasible( } const f_t new_lhs = fj_cpu.h_lhs[r] + (change - fj_cpu.h_lhs_sumcomp[r]); - if (fj_cpu.view.excess_score(r, new_lhs, c_lb, c_ub) < - -fj_cpu.view.get_corrected_tolerance(r, c_lb, c_ub)) + if (fj_cpu.view.excess_score(r, new_lhs, c_lb, c_ub) < -(f_t)fj_cpu.h_cstr_tolerance[r]) return false; } return true; @@ -1559,8 +1610,7 @@ static thrust::tuple find_lift_2opt_mov const i_t r = fj_cpu.h_reverse_constraints[k]; const f_t new_lhs = fj_cpu.h_lhs[r] + ((f_t)fj_cpu.h_reverse_coefficients[k] * delta1 - fj_cpu.h_lhs_sumcomp[r]); - if (fj_cpu.view.excess_score(r, new_lhs, c_lb, c_ub) < - -fj_cpu.view.get_corrected_tolerance(r, c_lb, c_ub)) { + if (fj_cpu.view.excess_score(r, new_lhs, c_lb, c_ub) < -(f_t)fj_cpu.h_cstr_tolerance[r]) { if (broken >= 0) multiple = true; else @@ -1633,6 +1683,7 @@ static thrust::tuple find_lift_move( const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); const f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); const f_t* const row_sumcomp = fj_cpu.view.incumbent_lhs_sumcomp.data(); + const f_t* const row_tol = fj_cpu.h_cstr_tolerance.data(); const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); bool breaks_a_row = false; @@ -1645,8 +1696,7 @@ static thrust::tuple find_lift_move( const f_t lhs = row_lhs[cstr_idx]; const f_t sumcomp = row_sumcomp[cstr_idx]; const f_t new_lhs = lhs + (cstr_coeff * delta - sumcomp); - if (fj_cpu.view.excess_score(cstr_idx, new_lhs, c_lb, c_ub) < - -fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub)) { + if (fj_cpu.view.excess_score(cstr_idx, new_lhs, c_lb, c_ub) < -row_tol[cstr_idx]) { breaks_a_row = true; break; } @@ -1669,7 +1719,7 @@ static thrust::tuple find_lift_move( auto cstr_coeff = fj_cpu.h_reverse_coefficients[j]; f_t c_lb = fj_cpu.h_cstr_lb[cstr_idx]; f_t c_ub = fj_cpu.h_cstr_ub[cstr_idx]; - f_t cstr_tolerance = fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + f_t cstr_tolerance = fj_cpu.h_cstr_tolerance[cstr_idx]; cuopt_assert(c_lb <= c_ub, "invalid bounds"); cuopt_assert(fj_cpu.view.cstr_satisfied(cstr_idx, fj_cpu.h_lhs[cstr_idx]), "cstr should be satisfied"); @@ -1781,13 +1831,14 @@ static void perturb(fj_cpu_climber_t& fj_cpu) std::sample(fj_cpu.h_objective_vars.begin(), fj_cpu.h_objective_vars.end(), std::back_inserter(sampled_vars), - 2, + std::max(1, fj_cpu.perturb_vars), fj_cpu.rng); raft::random::PCGenerator rng(fj_cpu.settings.seed + fj_cpu.iterations, 0, 0); for (auto var_idx : sampled_vars) randomize_variable(fj_cpu, var_idx, rng); + ++fj_cpu.n_lhs_recompute_perturb; recompute_lhs(fj_cpu); } @@ -1803,6 +1854,7 @@ static void reset_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) template static void invalidate_mtm_cache(fj_cpu_climber_t& fj_cpu) { + ++fj_cpu.n_mtm_cache_invalidations; for (size_t c = 0; c < fj_cpu.h_cstr_version.size(); ++c) fj_cpu.h_cstr_version[c]++; } @@ -1813,6 +1865,7 @@ static void restart_from_infeasible_checkpoint(fj_cpu_climber_t& fj_cp cuopt_assert(fj_cpu.h_assignment.size() == fj_cpu.h_best_infeasible_assignment.size(), "incumbent_assignment span would be invalidated"); fj_cpu.h_assignment = fj_cpu.h_best_infeasible_assignment; + ++fj_cpu.n_lhs_recompute_restart; recompute_lhs(fj_cpu); invalidate_mtm_cache(fj_cpu); } @@ -1859,6 +1912,7 @@ static void track_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) for (i_t var_idx = 0; var_idx < fj_cpu.view.pb.n_variables; ++var_idx) randomize_variable(fj_cpu, var_idx, rng); + ++fj_cpu.n_lhs_recompute_restart; recompute_lhs(fj_cpu); invalidate_mtm_cache(fj_cpu); reset_infeasible_checkpoint(fj_cpu); @@ -2015,6 +2069,45 @@ static void init_fj_cpu_from_template(fj_cpu_climber_t& fj_cpu, tmpl.view.pb.tolerances); } +// Certifies the epigraph variables: continuous, in the objective, and appearing in every one of +// their rows only on the side the objective pulls away from, with that direction unbounded. +template +static void certify_epigraph_variables(fj_cpu_climber_t& fj_cpu, i_t n_variables) +{ + fj_cpu.epigraph_push.assign(n_variables, 0); + fj_cpu.epigraph_vars.clear(); + + for (i_t var = 0; var < n_variables; ++var) { + if (is_integer_var(fj_cpu, var)) continue; + const f_t obj_coeff = fj_cpu.h_obj_coeffs[var]; + if (obj_coeff == f_t{0}) continue; + + const auto [begin, end] = reverse_range_for_var(fj_cpu, var); + if (begin == end) continue; + + // A positive coefficient is minimised by pushing the variable down, so its rows must be the + // only thing holding it up, and it must be free to rise as far as they demand. + const bool push_up = obj_coeff > f_t{0}; + const auto bounds = fj_cpu.h_var_bounds[var].get(); + if (isfinite(push_up ? get_upper(bounds) : get_lower(bounds))) continue; + + bool certified = true; + for (i_t p = begin; p < end && certified; ++p) { + const i_t row = fj_cpu.h_reverse_constraints[p]; + const f_t coeff = fj_cpu.h_reverse_coefficients[p]; + const bool has_lb = isfinite((f_t)fj_cpu.h_cstr_lb[row]); + const bool has_ub = isfinite((f_t)fj_cpu.h_cstr_ub[row]); + if (coeff == f_t{0}) continue; + certified = push_up ? ((coeff > 0 && has_lb && !has_ub) || (coeff < 0 && has_ub && !has_lb)) + : ((coeff > 0 && has_ub && !has_lb) || (coeff < 0 && has_lb && !has_ub)); + } + if (!certified) continue; + + fj_cpu.epigraph_push[var] = push_up ? 1 : -1; + fj_cpu.epigraph_vars.push_back(var); + } +} + template static void set_host_data_view( fj_cpu_climber_t& fj_cpu, @@ -2107,9 +2200,16 @@ static void wire_fj_cpu_host_views( fj_cpu.cached_mtm_moves_version.assign(fj_cpu.h_coefficients.size(), -1); fj_cpu.h_cstr_version.assign(n_constraints, 0); - fj_cpu.flip_move_computed.resize(n_variables, false); - fj_cpu.var_bitmap.resize(n_variables, false); - fj_cpu.iter_mtm_vars.reserve(n_variables); + fj_cpu.flip_move_stamp.assign(n_variables, 0); + fj_cpu.flip_move_epoch = 1; + + fj_cpu.h_cstr_tolerance.resize(n_constraints); + for (i_t row = 0; row < n_constraints; ++row) { + fj_cpu.h_cstr_tolerance[row] = + fj_cpu.view.get_corrected_tolerance(row, fj_cpu.h_cstr_lb[row], fj_cpu.h_cstr_ub[row]); + } + + certify_epigraph_variables(fj_cpu, n_variables); } template @@ -2167,10 +2267,6 @@ void finalize_fj_cpu_host_initialization( } fj_cpu.h_binrow_offsets[n_constraints] = fj_cpu.h_binrow_vars.size(); - fj_cpu.flip_move_computed.resize(n_variables, false); - fj_cpu.var_bitmap.resize(n_variables, false); - fj_cpu.iter_mtm_vars.reserve(n_variables); - // Must precede recompute_lhs, which is what first populates them. fj_cpu.violated_constraints.resize(n_constraints); fj_cpu.satisfied_constraints.resize(n_constraints); @@ -2201,8 +2297,13 @@ static void finalize_fj_cpu_host_initialization_from_template( cuopt_assert(tmpl.cached_cstr_bounds.size() == fj_cpu.h_reverse_coefficients.size(), "template cached bounds mismatch"); + cuopt_assert(tmpl.h_binrow_offsets.size() == static_cast(n_constraints + 1), + "template binrow offsets mismatch"); + fj_cpu.h_objective_vars = tmpl.h_objective_vars; fj_cpu.cached_cstr_bounds = tmpl.cached_cstr_bounds; + fj_cpu.h_binrow_offsets = tmpl.h_binrow_offsets; + fj_cpu.h_binrow_vars = tmpl.h_binrow_vars; fj_cpu.obj_magnitude = tmpl.obj_magnitude; fj_cpu.h_lhs = tmpl.h_lhs; @@ -2561,7 +2662,18 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w const i_t nnz_stretch = std::min( (i_t)fj_cpu->h_coefficients.size() / fj_nnz_per_refresh_stretch, fj_max_refresh_stretch); const i_t refresh_period = fj_cpu->settings.parameters.lhs_refresh_period * (1 + nnz_stretch); + //const i_t refresh_period = 5000 * (1 + nnz_stretch); cuopt_assert(refresh_period > 0, "refresh period overflowed"); + fj_cpu->lhs_refresh_period_used = refresh_period; + + // Whatever the seed left behind, these rows are satisfiable on their own, so the walk should not + // start with them in the violated set competing for the sampler's attention. + for (i_t var : fj_cpu->epigraph_vars) { + const f_t delta = project_epigraph_variable(*fj_cpu, var) - (f_t)fj_cpu->h_assignment[var]; + if (delta == f_t{0}) continue; + apply_move(*fj_cpu, var, delta, false); + ++fj_cpu->n_epigraph_projections; + } while (!fj_cpu->halted && !fj_cpu->preemption_flag.load()) { // Check if 5 seconds have passed @@ -2584,9 +2696,13 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w // periodically recompute the LHS and violation scores // to correct any accumulated numerical errors - if (fj_cpu->iterations % refresh_period == 0 || fj_cpu->trigger_early_lhs_recomputation) { + if (fj_cpu->trigger_early_lhs_recomputation) { + ++fj_cpu->n_lhs_recompute_bigval; recompute_lhs(*fj_cpu); fj_cpu->trigger_early_lhs_recomputation = false; + } else if (fj_cpu->iterations % refresh_period == 0) { + ++fj_cpu->n_lhs_recompute_periodic; + recompute_lhs(*fj_cpu); } fj_move_t move = fj_move_t{-1, 0}; @@ -2624,6 +2740,17 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w thrust::tie(move, score) = find_mtm_move_sat(*fj_cpu, fj_cpu->mtm_sat_samples); if (score > fj_staged_score_t::zero()) is_mtm_sat = true; } + // The scorers target one row at a time, so on an epigraph variable they climb toward the bound + // its rows already imply. The projection lands there in one move at the same O(degree) cost. + if (move.var_idx >= 0 && fj_cpu->epigraph_push[move.var_idx] != 0) { + const f_t projected = project_epigraph_variable(*fj_cpu, move.var_idx) - + (f_t)fj_cpu->h_assignment[move.var_idx]; + if (projected != f_t{0}) { + move.value = projected; + ++fj_cpu->n_epigraph_projections; + } + } + // if we're in the feasible region but haven't found improvements in the last n iterations, // perturb bool should_perturb = false; @@ -3800,6 +3927,20 @@ void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, i climber.mtm_sat_samples = std::uniform_int_distribution(10, 30)(rng); climber.nnz_samples = std::uniform_int_distribution(2000, 15000)(rng); climber.perturb_interval = std::uniform_int_distribution(50, 500)(rng); + //climber.perturb_vars = std::uniform_int_distribution(2, 8)(rng); + + // The objective weight below is inert until a lane crosses, so without these the whole portfolio + // runs one weight decay, one tabu tenure and one restart policy while it is still infeasible. + // const double smoothing_ladder[8] = {0.0003, 0.0, 0.001, 0.003, 0.0001, 0.0006, 0.002, 0.0003}; + // const int tabu_min_ladder[8] = {3, 1, 5, 3, 2, 6, 4, 3}; + // const int tabu_max_ladder[8] = {13, 7, 21, 13, 10, 25, 17, 13}; + // const i_t restart_window_ladder[8] = {300, 150, 500, 300, 200, 600, 400, 300}; + // const f_t degrade_ratio_ladder[8] = {1.15, 1.05, 1.30, 1.15, 1.08, 1.40, 1.20, 1.15}; + // climber.settings.parameters.weight_smoothing_probability = smoothing_ladder[lane % 8]; + // climber.settings.parameters.tabu_tenure_min = tabu_min_ladder[lane % 8]; + // climber.settings.parameters.tabu_tenure_max = tabu_max_ladder[lane % 8]; + // climber.infeasible_restart_window = restart_window_ladder[lane % 8]; + // climber.infeasible_restart_degrade_ratio = degrade_ratio_ladder[lane % 8]; climber.enable_infeasible_repair = (lane % 8 == 1) || (lane % 8 == 5); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 5c65f0e383..c251388f1f 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -142,6 +142,7 @@ struct fj_cpu_climber_t { ADD_INSTRUMENTED(h_var_bounds), ADD_INSTRUMENTED(h_cstr_lb), ADD_INSTRUMENTED(h_cstr_ub), + ADD_INSTRUMENTED(h_cstr_tolerance), ADD_INSTRUMENTED(h_var_types), ADD_INSTRUMENTED(h_is_binary_variable), ADD_INSTRUMENTED(h_objective_vars), @@ -163,8 +164,7 @@ struct fj_cpu_climber_t { ADD_INSTRUMENTED(h_assignment), ADD_INSTRUMENTED(h_best_assignment), ADD_INSTRUMENTED(h_best_infeasible_assignment), - ADD_INSTRUMENTED(cached_cstr_bounds), - ADD_INSTRUMENTED(iter_mtm_vars)}; + ADD_INSTRUMENTED(cached_cstr_bounds)}; #undef ADD_INSTRUMENTED } @@ -189,6 +189,8 @@ struct fj_cpu_climber_t { ins_vector::type> h_var_bounds; ins_vector h_cstr_lb; ins_vector h_cstr_ub; + // get_corrected_tolerance of each row, held because the bounds it derives from never move. + ins_vector h_cstr_tolerance; ins_vector h_var_types; ins_vector h_is_binary_variable; ins_vector h_objective_vars; @@ -244,14 +246,41 @@ struct fj_cpu_climber_t { std::vector update_weights_times; std::vector compute_score_times; - i_t hit_count{0}; - i_t miss_count{0}; + int64_t hit_count{0}; + int64_t miss_count{0}; i_t candidate_move_hits[3] = {0}; i_t candidate_move_misses[3] = {0}; - // vector is actually likely beneficial here since we're memory bound - std::vector flip_move_computed; + // Hot-loop accounting, reported off the clock by the standalone harness. + int64_t n_moves_applied{0}; + int64_t apply_move_nnz{0}; + int64_t n_mtm_calls{0}; + // Row entries find_mtm_move visits, and the ones the per-row cap kept it from visiting. + int64_t mtm_row_entries{0}; + int64_t mtm_entries_capped{0}; + int64_t n_compute_score_calls{0}; + int64_t compute_score_nnz{0}; + int64_t n_version_bumps_apply{0}; + int64_t n_version_bumps_weights{0}; + int64_t n_mtm_cache_invalidations{0}; + int64_t n_lhs_recompute_total{0}; + int64_t n_lhs_recompute_periodic{0}; + int64_t n_lhs_recompute_bigval{0}; + int64_t n_lhs_recompute_perturb{0}; + int64_t n_lhs_recompute_restart{0}; + i_t lhs_refresh_period_used{0}; + + // A variable's flip move has already been considered when its stamp equals flip_move_epoch, + // which advances once per applied move. An epoch avoids clearing an n_variables bitmap per move. + std::vector flip_move_stamp; + int64_t flip_move_epoch{1}; + + // Continuous objective variables bounded by their rows only opposite the objective's pull, so + // the tightest row bound is their value. epigraph_push is +1 pushing up, -1 pushing down. + std::vector epigraph_push; + std::vector epigraph_vars; + int64_t n_epigraph_projections{0}; // CSR nnz offset -> (delta, score) std::vector> cached_mtm_moves; @@ -264,9 +293,6 @@ struct fj_cpu_climber_t { // std::pair better compile down to 16 bytes!! GCC do your job! ins_vector> cached_cstr_bounds; - std::vector var_bitmap; - ins_vector iter_mtm_vars; - // Scratch reused by the binary 2-opt search, which runs at every local minimum std::vector two_opt_target_cstrs; std::vector two_opt_first_vars; @@ -286,6 +312,8 @@ struct fj_cpu_climber_t { i_t mtm_sat_samples{15}; i_t nnz_samples{50000}; i_t perturb_interval{100}; + // Number of variables randomized by one perturbation. + i_t perturb_vars{2}; // One lane replaces its start with a rounded LP relaxation, solved inside that lane's own task so // portfolio construction does not wait on an LP. bool use_lp_seed{false}; @@ -330,8 +358,8 @@ struct fj_cpu_climber_t { i_t iterations_since_best{0}; // Cache and locality tracking - i_t hit_count_window_start{0}; - i_t miss_count_window_start{0}; + int64_t hit_count_window_start{0}; + int64_t miss_count_window_start{0}; std::unordered_set unique_cstrs_accessed_window; std::unordered_set unique_vars_accessed_window; From d32d935b6b2c19d28f92c566a3422277734479c9 Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 24 Aug 2026 08:08:17 -0700 Subject: [PATCH 70/78] bounds prop in CPUFJ --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 190 +++++++++++++++++- .../feasibility_jump/fj_cpu.cuh | 3 + 2 files changed, 191 insertions(+), 2 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 2ee9378a9f..7e38334340 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -2528,6 +2528,178 @@ constexpr int32_t fj_max_refresh_stretch = 8; constexpr int64_t fj_lp_seed_nnz_limit = 8'000'000; constexpr double fj_lp_seed_budget_s = 2; +constexpr int32_t fj_bound_prop_rounds = 10; +// A deduction is committed only when it moves a bound by more than this many absolute tolerances. +constexpr double fj_bound_prop_commit_scale = 1e3; + +// Raises a lower bound to a deduced limit. Returns whether the domain moved. +template +static bool tighten_lower_bound(fj_cpu_climber_t& fj_cpu, + std::vector& lower, + const std::vector& upper, + i_t var, + f_t limit, + f_t commit_threshold) +{ + if (!isfinite(limit)) return false; + if (is_integer_var(fj_cpu, var)) + limit = ceil(limit - fj_cpu.view.pb.tolerances.integrality_tolerance); + if (limit > upper[var]) return false; + if (limit <= lower[var] + commit_threshold) return false; + lower[var] = limit; + return true; +} + +// Lowers an upper bound to a deduced limit. Returns whether the domain moved. +template +static bool tighten_upper_bound(fj_cpu_climber_t& fj_cpu, + const std::vector& lower, + std::vector& upper, + i_t var, + f_t limit, + f_t commit_threshold) +{ + if (!isfinite(limit)) return false; + if (is_integer_var(fj_cpu, var)) + limit = floor(limit + fj_cpu.view.pb.tolerances.integrality_tolerance); + if (limit < lower[var]) return false; + if (limit >= upper[var] - commit_threshold) return false; + upper[var] = limit; + return true; +} + +// Narrows this lane's domains by activity propagation, then reclassifies: an integer squeezed to +// [0,1] becomes eligible for the binary engine. +template +static void apply_bound_propagation(fj_cpu_climber_t& fj_cpu) +{ + if (!fj_cpu.use_bound_prop) return; + + const i_t n_variables = fj_cpu.view.pb.n_variables; + const i_t n_constraints = fj_cpu.view.pb.n_constraints; + const f_t commit = + (f_t)fj_bound_prop_commit_scale * fj_cpu.view.pb.tolerances.absolute_tolerance; + + std::vector lower(n_variables); + std::vector upper(n_variables); + for (i_t var = 0; var < n_variables; ++var) { + auto bounds = fj_cpu.h_var_bounds[var].get(); + lower[var] = get_lower(bounds); + upper[var] = get_upper(bounds); + } + + bool changed = true; + int32_t pass = 0; + for (; changed && pass < fj_bound_prop_rounds; ++pass) { + changed = false; + for (i_t row = 0; row < n_constraints; ++row) { + const f_t row_lb = fj_cpu.h_cstr_lb[row]; + const f_t row_ub = fj_cpu.h_cstr_ub[row]; + const bool has_lb = isfinite(row_lb); + const bool has_ub = isfinite(row_ub); + if (!has_lb && !has_ub) continue; + + const i_t begin = fj_cpu.h_offsets[row]; + const i_t end = fj_cpu.h_offsets[row + 1]; + + f_t min_activity = 0; + f_t max_activity = 0; + bool finite_min = true; + bool finite_max = true; + for (i_t p = begin; p < end; ++p) { + const f_t coeff = fj_cpu.h_coefficients[p]; + if (coeff == f_t{0}) continue; + const i_t var = fj_cpu.h_variables[p]; + const f_t min_x = coeff > 0 ? lower[var] : upper[var]; + const f_t max_x = coeff > 0 ? upper[var] : lower[var]; + finite_min &= isfinite(min_x); + finite_max &= isfinite(max_x); + if (finite_min) min_activity += coeff * min_x; + if (finite_max) max_activity += coeff * max_x; + } + + const bool from_row_ub = finite_min && has_ub; + const bool from_row_lb = finite_max && has_lb; + if (!from_row_ub && !from_row_lb) continue; + + // The activities are not refreshed as the loop below narrows the row's own variables, and a + // stale bound is the looser one, so a deduction taken against it is the weaker one. + for (i_t p = begin; p < end; ++p) { + const f_t coeff = fj_cpu.h_coefficients[p]; + if (coeff == f_t{0}) continue; + const i_t var = fj_cpu.h_variables[p]; + + if (from_row_ub) { + const f_t rest = min_activity - coeff * (coeff > 0 ? lower[var] : upper[var]); + const f_t limit = (row_ub - rest) / coeff; + changed |= coeff > 0 ? tighten_upper_bound(fj_cpu, lower, upper, var, limit, commit) + : tighten_lower_bound(fj_cpu, lower, upper, var, limit, commit); + } + if (from_row_lb) { + const f_t rest = max_activity - coeff * (coeff > 0 ? upper[var] : lower[var]); + const f_t limit = (row_lb - rest) / coeff; + changed |= coeff > 0 ? tighten_lower_bound(fj_cpu, lower, upper, var, limit, commit) + : tighten_upper_bound(fj_cpu, lower, upper, var, limit, commit); + } + } + } + } + + fj_cpu.h_binary_indices.clear(); + fj_cpu.n_binary_vars = 0; + fj_cpu.n_integer_vars = 0; + i_t tightened = 0; + bool clamped = false; + for (i_t var = 0; var < n_variables; ++var) { + auto bounds = fj_cpu.h_var_bounds[var].get(); + cuopt_assert(!(lower[var] < get_lower(bounds)), "propagation widened a lower bound"); + cuopt_assert(!(upper[var] > get_upper(bounds)), "propagation widened an upper bound"); + cuopt_assert(!(lower[var] > upper[var]), "propagation emptied a domain"); + const bool moved = lower[var] != get_lower(bounds) || upper[var] != get_upper(bounds); + + // Same rule as problem_t::compute_binary_var_table, fixed binaries included: a domain narrowed + // to a point is no longer binary. + const bool integer = is_integer_var(fj_cpu, var); + const bool binary = integer && fj_cpu.view.pb.integer_equal(lower[var], (f_t)0) && + fj_cpu.view.pb.integer_equal(upper[var], (f_t)1); + fj_cpu.h_is_binary_variable[var] = binary; + if (binary) { + fj_cpu.h_binary_indices.push_back(var); + ++fj_cpu.n_binary_vars; + } else if (integer) { + ++fj_cpu.n_integer_vars; + } + if (!moved) continue; + + ++tightened; + fj_cpu.h_var_bounds[var] = typename type_2::type{lower[var], upper[var]}; + + const f_t value = fj_cpu.h_assignment[var]; + const f_t clamped_value = std::clamp(value, lower[var], upper[var]); + if (clamped_value != value) { + cuopt_assert(!integer || fj_cpu.view.pb.is_integer(clamped_value), + "bound clamp broke integrality"); + fj_cpu.h_assignment[var] = clamped_value; + clamped = true; + } + fj_cpu.h_best_assignment[var] = + std::clamp((f_t)fj_cpu.h_best_assignment[var], lower[var], upper[var]); + } + + // h_binary_indices reallocated, so the span over it would otherwise dangle. + fj_cpu.view.pb.binary_indices = + raft::device_span(fj_cpu.h_binary_indices.data(), fj_cpu.h_binary_indices.size()); + + if (clamped) recompute_lhs(fj_cpu); + + CUOPT_LOG_DEBUG("%sCPUFJ bound prop: %d passes, %d domains tightened, %d binary of %d integer", + fj_cpu.log_prefix.c_str(), + pass, + tightened, + fj_cpu.n_binary_vars, + fj_cpu.n_binary_vars + fj_cpu.n_integer_vars); +} + // Rounds the LP relaxation into this lane's start point. Solved by dual simplex on the lane's own // thread rather than during portfolio construction, so the other seven start searching immediately. template @@ -2633,14 +2805,24 @@ template void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double work_unit_limit) { const auto solve_start = std::chrono::high_resolution_clock::now(); + // Precedes the dispatch below because a variable it squeezes to [0,1] can bring the whole model + // into the binary engine's shape. + apply_bound_propagation(*fj_cpu); + const f_t prop_seconds = + fj_cpu->use_bound_prop + ? std::chrono::duration(std::chrono::high_resolution_clock::now() - solve_start).count() + : f_t{0}; + // problem fits the binary fastpath shape? run it (engine is solve-local) - if (try_cpufj_binary_solve(*fj_cpu, in_time_limit, work_unit_limit)) return; + if (try_cpufj_binary_solve(*fj_cpu, in_time_limit - prop_seconds, work_unit_limit)) return; apply_lp_rounded_seed(*fj_cpu); i_t local_mins = 0; // The LP comes out of this lane's own budget; every other lane's clock starts where it did. - auto loop_start = fj_cpu->use_lp_seed ? solve_start : std::chrono::high_resolution_clock::now(); + auto loop_start = (fj_cpu->use_lp_seed || fj_cpu->use_bound_prop) + ? solve_start + : std::chrono::high_resolution_clock::now(); auto time_limit = std::chrono::milliseconds(static_cast(std::floor(in_time_limit * 1000.0))); auto loop_time_start = loop_start; @@ -3911,6 +4093,10 @@ void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, i // One structural start per lane; lanes 0 and 4 keep the shared anchor. Lane 7 also keeps it here, // because its replacement is an LP solved inside that lane's own task rather than in setup. climber.use_lp_seed = lane % 8 == 7; + + // Half the portfolio searches the propagated model, half the model as parsed. Off the LP lane, so + // one lane does not carry both setup passes. + climber.use_bound_prop = lane % 2 == 0; switch (lane % 8) { case 1: apply_lock_weighted_seed(climber); break; case 2: apply_aggressive_constraint_seed(climber); break; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index c251388f1f..9bd02103b2 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -317,6 +317,9 @@ struct fj_cpu_climber_t { // One lane replaces its start with a rounded LP relaxation, solved inside that lane's own task so // portfolio construction does not wait on an LP. bool use_lp_seed{false}; + // Half the lanes narrow their own domains by activity propagation before searching, so the + // portfolio covers both the propagated and the as-parsed model. + bool use_bound_prop{false}; // Enables the binary engine's infeasible-phase pair repair. Per lane, since the pair scan costs // iterations that a well-tuned single-flip lane would rather spend elsewhere. bool enable_infeasible_repair{false}; From 98130fc17bcb6366f88253b862816e26449cbcab Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 24 Aug 2026 08:45:40 -0700 Subject: [PATCH 71/78] ddfw scheme --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 59 +++++++++++++++++++ .../feasibility_jump/fj_cpu.cuh | 2 + 2 files changed, 61 insertions(+) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 7e38334340..e61bc18697 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -973,6 +973,58 @@ static void smooth_weights(fj_cpu_climber_t& fj_cpu) constexpr int32_t fj_weight_escalate_after = 2000; constexpr int32_t fj_weight_escalate_max = 100; +// Satisfied neighbours sampled per violated row for the donation, and the floor a donor keeps. +constexpr int32_t fj_weight_donor_samples = 4; +constexpr double fj_weight_donation_floor = 1.0; + +// DDFW donation: reach through a variable of this violated row to a satisfied neighbour and take +// the bump back off its heavier side, so total weight stays roughly conserved. +template +static void donate_row_weight(fj_cpu_climber_t& fj_cpu, + i_t cstr_idx, + f_t delta, + raft::random::PCGenerator& rng) +{ + const auto [row_begin, row_end] = range_for_constraint(fj_cpu, cstr_idx); + const uint32_t row_width = (uint32_t)(row_end - row_begin); + // What a donor has to carry to still hold the floor once the delta comes off it. + const f_t donor_minimum = (f_t)fj_weight_donation_floor + delta; + i_t donor = -1; + bool donor_left = true; + f_t donor_weight = 0; + + for (i_t sample = 0; row_width > 0 && sample < fj_weight_donor_samples; ++sample) { + const i_t var_idx = fj_cpu.h_variables[row_begin + (i_t)(rng.next_u32() % row_width)]; + const auto [col_begin, col_end] = reverse_range_for_var(fj_cpu, var_idx); + if (col_end <= col_begin) continue; + const i_t candidate = fj_cpu.h_reverse_constraints[ + col_begin + (i_t)(rng.next_u32() % (uint32_t)(col_end - col_begin))]; + if (candidate == cstr_idx || !fj_cpu.satisfied_constraints.contains(candidate)) continue; + + const f_t left = fj_cpu.h_cstr_left_weights[candidate]; + const f_t right = fj_cpu.h_cstr_right_weights[candidate]; + const bool take_left = left >= right; + const f_t weight = take_left ? left : right; + if (weight < donor_minimum) continue; + if (donor >= 0 && weight <= donor_weight) continue; + + donor = candidate; + donor_left = take_left; + donor_weight = weight; + } + if (donor < 0) return; + + const f_t donated = donor_weight - delta; + cuopt_assert(donated >= (f_t)fj_weight_donation_floor, "donation broke the weight floor"); + if (donor_left) { + fj_cpu.h_cstr_left_weights[donor] = donated; + } else { + fj_cpu.h_cstr_right_weights[donor] = donated; + } + ++fj_cpu.n_version_bumps_weights; + fj_cpu.h_cstr_version[donor]++; +} + template static i_t weight_escalation_delta(const fj_cpu_climber_t& fj_cpu) { @@ -1029,6 +1081,11 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) fj_cpu.max_weight = max(fj_cpu.max_weight, new_weight); } + // Only before this lane's first crossing: past that the search oscillates in and out of + // feasibility, and draining satisfied rows costs the objective phase. + if (fj_cpu.use_weight_donation && !fj_cpu.feasible_found) + donate_row_weight(fj_cpu, cstr_idx, delta, rng); + // Invalidate related cached move scores ++fj_cpu.n_version_bumps_weights; fj_cpu.h_cstr_version[cstr_idx]++; @@ -4097,6 +4154,8 @@ void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, i // Half the portfolio searches the propagated model, half the model as parsed. Off the LP lane, so // one lane does not carry both setup passes. climber.use_bound_prop = lane % 2 == 0; + + climber.use_weight_donation = (lane % 8 == 5) || (lane % 8 == 6); switch (lane % 8) { case 1: apply_lock_weighted_seed(climber); break; case 2: apply_aggressive_constraint_seed(climber); break; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 9bd02103b2..ceddadbbdc 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -320,6 +320,8 @@ struct fj_cpu_climber_t { // Half the lanes narrow their own domains by activity propagation before searching, so the // portfolio covers both the propagated and the as-parsed model. bool use_bound_prop{false}; + // Two lanes move weight from satisfied rows into the violated ones while still infeasible. + bool use_weight_donation{false}; // Enables the binary engine's infeasible-phase pair repair. Per lane, since the pair scan costs // iterations that a well-tuned single-flip lane would rather spend elsewhere. bool enable_infeasible_repair{false}; From e4caa19ece4d207441695580110a8bbe26cd12ef Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 24 Aug 2026 09:09:26 -0700 Subject: [PATCH 72/78] encode small domain integers as binary to enable the FJ binary fastpath --- .../feasibility_jump/fj_cpu_binary.cu | 345 +++++++++++++++++- 1 file changed, 334 insertions(+), 11 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index 8f7ec35a4e..b648b8a0b4 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -187,6 +188,15 @@ struct fj_bin_problem_t { // switching exactly one other off: the exchange a pair can represent. int32_t n_exchange_vars{0}; int32_t max_card_degree{0}; + + // Empty unless encoded, when every engine variable is one bit of a bounded general integer and + // original[j] = var_offset[j] + sum of bit_weight[b] * assign[b] over the bits b owned by j. + bool encoded{false}; + int32_t n_original{0}; + std::vector var_offset; + std::vector bit_owner; + std::vector bit_weight; + std::vector orig_objective; }; // Result of the width-independent eligibility scan. @@ -610,6 +620,237 @@ static bool fj_bin_narrow(const fj_cpu_climber_t& c, } +// Bit budget for one general integer's domain. +constexpr int32_t fj_bin_encode_max_bits = 16; +// Cap on the bit-variable count relative to the model's variable count, bounding the SIMD sweep. +constexpr int64_t fj_bin_encode_max_growth = 6; + +// Bits needed to represent the integers 0..W inclusive. +static inline int32_t fj_bin_encode_nbits(int64_t W) +{ + int32_t bits = 0; + while (((int64_t)1 << bits) - 1 < W) ++bits; + return bits; +} + +// Encodes an all-integer model with bounded general integers into bits: x in [L,U] becomes +// x = L + sum_k w_k b_k over weights 1, 2, ..., 2^(nbits-2), R, with R closing the range at W = U-L. +template +static bool fj_bin_encode(const fj_cpu_climber_t& c, + fj_bin_problem_t& pb, + int& coefficient_bits) +{ + const int32_t n = c.view.pb.n_variables; + const int32_t m = c.view.pb.n_constraints; + if (n <= 0 || m <= 0) return false; + + const double tol = c.view.pb.tolerances.integrality_tolerance; + + const auto& var_bounds = c.h_var_bounds; + const auto& var_types = c.h_var_types; + const auto& offsets = c.h_offsets; + const auto& variables = c.h_variables; + const auto& coeffs = c.h_coefficients; + const auto& cstr_lb = c.h_cstr_lb; + const auto& cstr_ub = c.h_cstr_ub; + const auto& left_w = c.h_cstr_left_weights; + const auto& right_w = c.h_cstr_right_weights; + const auto& obj = c.h_obj_coeffs; + + std::vector lower(n); + std::vector upper(n); + std::vector nbits(n); + std::vector bit_start(n); + int64_t total_bits = 0; + for (int32_t v = 0; v < n; ++v) { + if (var_types[v] != var_t::INTEGER) return false; + auto bounds = var_bounds[v]; + const double x = (double)cuopt::get_lower(bounds); + const double y = (double)cuopt::get_upper(bounds); + if (!std::isfinite(x) || !std::isfinite(y) || y < x) return false; + if (!is_integer(x, tol) || !is_integer(y, tol)) return false; + + lower[v] = std::round(x); + upper[v] = std::round(y); + const int64_t W = (int64_t)(upper[v] - lower[v]); + + nbits[v] = fj_bin_encode_nbits(W); + if (nbits[v] > fj_bin_encode_max_bits) return false; + bit_start[v] = (int32_t)total_bits; + total_bits += nbits[v]; + } + if (total_bits <= 0 || total_bits > (int64_t)INT32_MAX / 2) return false; + if (total_bits > fj_bin_encode_max_growth * (int64_t)n) return false; + + const int32_t n_bits = (int32_t)total_bits; + + pb.encoded = true; + pb.n_original = n; + pb.var_offset = lower; + pb.orig_objective.assign(n, 0.0); + pb.bit_owner.assign(n_bits, 0); + pb.bit_weight.assign(n_bits, 0.0); + for (int32_t v = 0; v < n; ++v) { + int64_t covered = 0; + const int64_t W = (int64_t)(upper[v] - lower[v]); + for (int32_t k = 0; k < nbits[v]; ++k) { + const int64_t w = k + 1 < nbits[v] ? (int64_t)1 << k : W - covered; + covered += w; + pb.bit_owner[bit_start[v] + k] = v; + pb.bit_weight[bit_start[v] + k] = (double)w; + } + cuopt_assert(covered == W, "bit weights do not close the domain exactly"); + } + + pb.n_variables = n_bits; + pb.offsets.assign(1, 0); + pb.bound.clear(); + pb.cmax.clear(); + pb.initial_weight.clear(); + pb.variables.clear(); + pb.coefficients.clear(); + + std::vector incoming_weight; + std::vector row_values; + double max_abs_coefficient = 0; + + // One side of one row, as a'b <= bound in bit space with sum(a_j L_j) folded into the bound. + auto emit = [&](int32_t r, double side_bound, long side, double weight) -> bool { + double fixed = 0; + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) + fixed += coeffs[k] * lower[variables[k]]; + const double folded_bound = side_bound - fixed; + + row_values.clear(); + bool integral = is_integer(folded_bound, tol); + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { + row_values.push_back(coeffs[k]); + if (!is_integer(coeffs[k], tol)) integral = false; + } + row_values.push_back(folded_bound); + + double s = 1.0; + if (!integral) { + s = find_scaling_rational( + row_values, 1.0 / tol, fj_bin_scale_cap, (double)fj_bin_scale_cap, tol); + if (!std::isfinite(s) || s <= 0.0) return false; + } + + coef_t row_cmax = 1; + double row_abs_sum = 0; + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { + const int32_t v = variables[k]; + const double a = s * coeffs[k]; + if (!is_integer(a, tol)) return false; + const long ai = std::lround(a); + for (int32_t bk = 0; bk < nbits[v]; ++bk) { + const int32_t bit = bit_start[v] + bk; + const long scaled = side * ai * std::lround(pb.bit_weight[bit]); + const long abs_a = std::labs(scaled); + // Bounded by magnitude, so cmax below and the negated side both stay representable. + if (abs_a > (long)std::numeric_limits::max()) return false; + pb.variables.push_back(bit); + pb.coefficients.push_back((coef_t)scaled); + + if (abs_a > (long)row_cmax) row_cmax = (coef_t)abs_a; + row_abs_sum += (double)abs_a; + if ((double)abs_a > max_abs_coefficient) max_abs_coefficient = (double)abs_a; + } + } + if (row_abs_sum > (double)(INT32_MAX / 2)) return false; + + const double scaled_bound = side * s * folded_bound; + if (!is_integer(scaled_bound, tol)) return false; + const double bound = std::round(scaled_bound); + if (!fj_bin_in_int32(bound)) return false; + // A bit assignment can drive lhs anywhere in [-row_abs_sum, row_abs_sum]. + if (!fj_bin_in_int32(bound - row_abs_sum) || !fj_bin_in_int32(bound + row_abs_sum)) return false; + + pb.offsets.push_back((int32_t)pb.variables.size()); + pb.bound.push_back((int32_t)bound); + pb.cmax.push_back(row_cmax); + incoming_weight.push_back(weight); + return true; + }; + + for (int32_t r = 0; r < m; ++r) { + const double lb = cstr_lb[r]; + const double ub = cstr_ub[r]; + if (std::isfinite(lb) && !emit(r, lb, -1, left_w[r])) return false; + if (std::isfinite(ub) && !emit(r, ub, 1, right_w[r])) return false; + } + pb.n_constraints = (int32_t)pb.bound.size(); + if (pb.n_constraints <= 0) return false; + pb.nnz = (int32_t)pb.variables.size(); + + if (max_abs_coefficient <= 127.0) { + coefficient_bits = 8; + } else if (max_abs_coefficient <= 32767.0) { + coefficient_bits = 16; + } else { + return false; + } + + pb.variables.resize(pb.nnz + fj_bin_simd_padding, 0); + pb.coefficients.resize(pb.nnz + fj_bin_simd_padding, (coef_t)0); + + double w_min = std::numeric_limits::infinity(); + for (double w : incoming_weight) { + if (w > 0 && w < w_min) w_min = w; + } + double scale = 1.0; + if (std::isfinite(w_min) && w_min > 0) { + scale = (double)fj_bin_ddfw_init / w_min; + if (scale < 1.0) scale = 1.0; + } + for (double w : incoming_weight) { + int32_t scaled = w > 0 ? (int32_t)std::lround(w * scale) : fj_bin_ddfw_init; + if (scaled < 1) scaled = 1; + pb.initial_weight.push_back(scaled); + } + + pb.reverse_offsets.assign(n_bits + 1, 0); + for (int32_t k = 0; k < pb.nnz; ++k) pb.reverse_offsets[pb.variables[k] + 1]++; + for (int32_t v = 0; v < n_bits; ++v) pb.reverse_offsets[v + 1] += pb.reverse_offsets[v]; + pb.reverse_constraints.resize(pb.nnz); + pb.reverse_coefficients.resize(pb.nnz); + pb.reverse_to_csr.resize(pb.nnz); + pb.incident_row_cmax.resize(pb.nnz); + { + std::vector cursor(pb.reverse_offsets.begin(), pb.reverse_offsets.begin() + n_bits); + for (int32_t r = 0; r < pb.n_constraints; ++r) { + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { + const int32_t slot = cursor[pb.variables[k]]++; + pb.reverse_constraints[slot] = r; + pb.reverse_coefficients[slot] = pb.coefficients[k]; + pb.reverse_to_csr[slot] = k; + pb.incident_row_cmax[slot] = pb.cmax[r]; + } + } + } + const int32_t rpad = fj_bin_pf_dist > fj_bin_simd_padding ? fj_bin_pf_dist : fj_bin_simd_padding; + pb.reverse_constraints.resize(pb.nnz + rpad, 0); + pb.reverse_coefficients.resize(pb.nnz + rpad, (coef_t)0); + pb.incident_row_cmax.resize(pb.nnz + rpad, (coef_t)1); + + pb.objective.assign(n_bits, 0.0); + pb.objective_vars.clear(); + for (int32_t v = 0; v < n; ++v) { + pb.orig_objective[v] = obj[v]; + if (obj[v] == 0.0) continue; + for (int32_t bk = 0; bk < nbits[v]; ++bk) { + const int32_t bit = bit_start[v] + bk; + pb.objective[bit] = obj[v] * pb.bit_weight[bit]; + if (pb.objective[bit] != 0.0) pb.objective_vars.push_back(bit); + } + } + + // The cardinality census only reads as a count on rows of plain binaries. + pb.n_exchange_vars = 0; + pb.max_card_degree = 0; + return true; +} + // The integer engine. Feasibility is an exact compare against one bound per row, so there is no // tolerance arithmetic and no compensated summation anywhere below. template @@ -671,6 +912,9 @@ struct fj_bin_engine_t { // Mean absolute nonzero objective coefficient; the unit of the objective score term. double obj_magnitude{1.0}; double incumbent_objective{0}; + // sum(obj_j * L_j), folded out of the encoded objective and carried here so both tracked + // objectives hold the model's own value. Zero on the all-binary path. + double objective_offset{0}; double best_objective{std::numeric_limits::infinity()}; int32_t max_weight{1}; bool feasible_found{false}; @@ -744,7 +988,7 @@ struct fj_bin_engine_t { } } - double objective = 0; + double objective = objective_offset; for (int32_t v = 0; v < pb.n_variables; ++v) objective += pb.objective[v] * (double)best_assign[v]; const double drift = std::fabs(objective - best_objective); @@ -845,7 +1089,7 @@ struct fj_bin_engine_t { row_slack[r] = slack; if (slack < 0) set_violated(r); } - incumbent_objective = 0; + incumbent_objective = objective_offset; for (int32_t v = 0; v < pb.n_variables; ++v) incumbent_objective += pb.objective[v] * assign[v]; nnz_touched += pb.nnz; rebuild_scores(); @@ -1021,9 +1265,22 @@ struct fj_bin_engine_t { { auto& h_assign = climber.h_assignment; auto& h_best = climber.h_best_assignment; - for (int32_t v = 0; v < pb.n_variables; ++v) { - h_assign[v] = (f_t)assign[v]; - h_best[v] = (f_t)assign[v]; + if (pb.encoded) { + for (int32_t v = 0; v < pb.n_original; ++v) { + h_assign[v] = (f_t)pb.var_offset[v]; + h_best[v] = (f_t)pb.var_offset[v]; + } + for (int32_t b = 0; b < pb.n_variables; ++b) { + if (!assign[b]) continue; + const int32_t v = pb.bit_owner[b]; + h_assign[v] += (f_t)pb.bit_weight[b]; + h_best[v] += (f_t)pb.bit_weight[b]; + } + } else { + for (int32_t v = 0; v < pb.n_variables; ++v) { + h_assign[v] = (f_t)assign[v]; + h_best[v] = (f_t)assign[v]; + } } climber.h_incumbent_objective = (f_t)incumbent_objective; climber.h_best_objective = (f_t)best_objective; @@ -1537,7 +1794,10 @@ struct fj_bin_engine_t { if (feasible_found) { cuopt_assert((int32_t)best_assign.size() == pb.n_variables, "incumbent size mismatch"); assign = best_assign; - if (shared_incumbent && shared_incumbent->adopt((f_t)best_objective, adopt_buffer)) { + // The shared buffer holds decoded integers, so the flat 0/1 read below only lines up when + // engine variables are the model's own variables. + if (!pb.encoded && shared_incumbent && + shared_incumbent->adopt((f_t)best_objective, adopt_buffer)) { for (int32_t v = 0; v < pb.n_variables; ++v) assign[v] = (int8_t)(adopt_buffer[v] >= 0.5 ? 1 : 0); } @@ -1608,10 +1868,33 @@ struct fj_bin_engine_t { const int32_t n = pb.n_variables, m = pb.n_constraints; const auto& h_assign = climber.h_assignment; - assign.resize(n); - for (int32_t v = 0; v < n; ++v) { - const double val = (double)h_assign[v]; - assign[v] = (int8_t)(val >= 0.5 ? 1 : 0); + assign.assign(n, 0); + if (pb.encoded) { + // Descending weight, so the bit pattern reproduces the start value wherever it is + // representable: with exact closure that is every integer of the domain. + std::vector> bits_of(pb.n_original); + for (int32_t b = 0; b < n; ++b) bits_of[pb.bit_owner[b]].push_back(b); + for (int32_t v = 0; v < pb.n_original; ++v) { + long residual = std::lround((double)h_assign[v] - pb.var_offset[v]); + if (residual < 0) residual = 0; + auto& bits = bits_of[v]; + std::sort(bits.begin(), bits.end(), [&](int32_t a, int32_t b) { + return pb.bit_weight[a] > pb.bit_weight[b]; + }); + for (int32_t b : bits) { + const long w = std::lround(pb.bit_weight[b]); + if (w <= residual) { + assign[b] = 1; + residual -= w; + } + } + cuopt_assert(residual == 0, "greedy bit encode left the start value unrepresented"); + } + } else { + for (int32_t v = 0; v < n; ++v) { + const double val = (double)h_assign[v]; + assign[v] = (int8_t)(val >= 0.5 ? 1 : 0); + } } seed_assign = assign; best_assign = assign; @@ -1645,6 +1928,12 @@ struct fj_bin_engine_t { cuopt_assert(std::isfinite(obj_magnitude) && obj_magnitude > 0, "objective magnitude unit must be finite and positive"); + objective_offset = 0; + if (pb.encoded) { + for (int32_t v = 0; v < pb.n_original; ++v) + objective_offset += pb.orig_objective[v] * pb.var_offset[v]; + } + argmax_tile = fj_bin_argmax_tile(); objective_weight = seeded_weight > 0 ? seeded_weight : 0; seed_objective_weight = objective_weight; @@ -1748,7 +2037,13 @@ struct fj_bin_engine_t { } if (iters % climber.diversity_callback_interval == 0 && climber.diversity_callback) { auto& h_assign = climber.h_assignment; - for (int32_t v = 0; v < pb.n_variables; ++v) h_assign[v] = (f_t)assign[v]; + if (pb.encoded) { + for (int32_t v = 0; v < pb.n_original; ++v) h_assign[v] = (f_t)pb.var_offset[v]; + for (int32_t b = 0; b < pb.n_variables; ++b) + if (assign[b]) h_assign[pb.bit_owner[b]] += (f_t)pb.bit_weight[b]; + } else { + for (int32_t v = 0; v < pb.n_variables; ++v) h_assign[v] = (f_t)assign[v]; + } climber.diversity_callback((f_t)incumbent_objective, h_assign); } @@ -1806,6 +2101,34 @@ bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, const fj_bin_scan_t scan = fj_bin_scan(climber); if (scan.reject != fj_binary_reject_t::none) { + // A non-binary variable is the one rejection the encoding can answer: the model may still be + // all-integer with finite domains. Every other reason fails the encoded model just the same. + if (scan.reject == fj_binary_reject_t::non_binary_var) { + // The width the encoded coefficients need is only known once they are built, so probe with + // int16 and rebuild on int8 for the narrower kernel when that is enough. + fj_bin_engine_t probe; + int bits = 0; + if (fj_bin_encode(climber, probe.pb, bits)) { + if (bits == 8) { + fj_bin_engine_t engine8; + int bits8 = 0; + if (fj_bin_encode(climber, engine8.pb, bits8)) { + CUOPT_LOG_DEBUG("%sCPUFJ binary fast path enabled (encoded int8): %d bits, %d rows", + climber.log_prefix.c_str(), + engine8.pb.n_variables, + engine8.pb.n_constraints); + engine8.solve(climber, time_limit, work_unit_limit); + return true; + } + } + CUOPT_LOG_DEBUG("%sCPUFJ binary fast path enabled (encoded int16): %d bits, %d rows", + climber.log_prefix.c_str(), + probe.pb.n_variables, + probe.pb.n_constraints); + probe.solve(climber, time_limit, work_unit_limit); + return true; + } + } CUOPT_LOG_DEBUG("%sCPUFJ binary fast path declined: %s (row %d, var %d)", climber.log_prefix.c_str(), fj_binary_reject_name(scan.reject), From 555848a976f0927357a528caa46ba1b6ab620ac1 Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 24 Aug 2026 09:54:01 -0700 Subject: [PATCH 73/78] feaspump in early cpufj --- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 312 ++++++++++++------ 1 file changed, 219 insertions(+), 93 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index e61bc18697..360c9da6ab 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -2583,7 +2583,133 @@ constexpr int32_t fj_max_refresh_stretch = 8; // Above this a short LP spends more time moving the matrix than it can pay back as a seed, and the // wall budget the LP is allowed out of the lane's own. constexpr int64_t fj_lp_seed_nnz_limit = 8'000'000; -constexpr double fj_lp_seed_budget_s = 2; +constexpr double fj_lp_pump_max_budget_s = 2.0; +constexpr double fj_lp_pump_budget_share = 0.25; +constexpr int32_t fj_lp_pump_projections = 3; + +// One dual simplex solve of a relaxation on the calling thread. Reports whether the returned point +// is usable: a vertex reached at a limit is dual feasible and still worth rounding. +template +static bool solve_lp_relaxation(const simplex::user_problem_t& relaxation, + double time_limit, + std::vector& x) +{ + simplex::lp_status_t status = simplex::lp_status_t::UNSET; + double seconds = 0; + + // solve_linear_program_advanced, whose status separates a limit -- which leaves a usable vertex + // behind -- from infeasibility. Guarded on f_t because dual simplex is only built for double. + if constexpr (std::is_same_v) { + simplex_solver_settings_t lp_settings; + lp_settings.relaxation = true; + lp_settings.time_limit = time_limit; + lp_settings.log.log = false; + // The portfolio already pins one CPU per lane, and the simplex default is + // omp_get_max_threads() - 1, which would open a second portfolio inside this lane's worker. + lp_settings.num_threads = 1; + + const f_t lp_start = tic(); + lp_problem_t converted(relaxation.handle_ptr, + relaxation.num_rows, + relaxation.num_cols, + relaxation.A.col_start[relaxation.A.n]); + std::vector new_slacks; + simplex::dualize_info_t dualize_info; + simplex::convert_user_problem(relaxation, lp_settings, converted, new_slacks, dualize_info); + + simplex::lp_solution_t lp_solution(converted.num_rows, converted.num_cols); + std::vector vstatus; + std::vector edge_norms; + status = simplex::solve_linear_program_advanced( + converted, lp_start, lp_settings, lp_solution, vstatus, edge_norms); + x = std::move(lp_solution.x); + seconds = toc(lp_start); + } + + const bool usable = status == simplex::lp_status_t::OPTIMAL || + status == simplex::lp_status_t::TIME_LIMIT || + status == simplex::lp_status_t::ITERATION_LIMIT || + status == simplex::lp_status_t::CONCURRENT_LIMIT || + status == simplex::lp_status_t::WORK_LIMIT; + CUOPT_LOG_DEBUG("CPUFJ LP relaxation: %s after %.3fs of %.3fs%s", + simplex::lp_status_to_string(status).c_str(), + seconds, + time_limit, + usable ? "" : ", discarded"); + return usable; +} + +// The L1 distance to a rounded point, as an exact LP. Every integer x gains a d with the pair +// x - d <= r and -x - d <= -r, so minimising sum(d) minimises sum(abs(x - r)). +template +static simplex::user_problem_t make_lp_distance_problem( + const simplex::user_problem_t& base, + fj_cpu_climber_t& fj_cpu, + const std::vector& rounded) +{ + std::vector integer_vars; + for (i_t var = 0; var < fj_cpu.view.pb.n_variables; ++var) + if (is_integer_var(fj_cpu, var)) integer_vars.push_back(var); + const i_t n_distance = (i_t)integer_vars.size(); + + simplex::user_problem_t result(base.handle_ptr); + result.num_rows = base.num_rows + 2 * n_distance; + result.num_cols = base.num_cols + n_distance; + + // The model's own objective is dropped: this LP measures distance alone. + result.objective.assign(result.num_cols, f_t{0}); + for (i_t k = 0; k < n_distance; ++k) result.objective[base.num_cols + k] = f_t{1}; + + result.lower = base.lower; + result.upper = base.upper; + result.lower.resize(result.num_cols, f_t{0}); + result.upper.resize(result.num_cols, std::numeric_limits::infinity()); + + result.rhs = base.rhs; + result.row_sense = base.row_sense; + result.rhs.reserve(result.num_rows); + result.row_sense.reserve(result.num_rows); + for (i_t k = 0; k < n_distance; ++k) { + result.rhs.push_back(rounded[integer_vars[k]]); + result.row_sense.push_back('L'); + result.rhs.push_back(-rounded[integer_vars[k]]); + result.row_sense.push_back('L'); + } + result.range_rows = base.range_rows; + result.range_value = base.range_value; + result.num_range_rows = base.num_range_rows; + + const i_t base_nnz = base.A.col_start[base.A.n]; + csc_matrix_t matrix(result.num_rows, result.num_cols, base_nnz + 4 * n_distance); + i_t out = 0; + i_t next_integer = 0; + for (i_t j = 0; j < base.num_cols; ++j) { + matrix.col_start[j] = out; + for (i_t p = base.A.col_start[j]; p < base.A.col_start[j + 1]; ++p) { + matrix.i[out] = base.A.i[p]; + matrix.x[out++] = base.A.x[p]; + } + if (next_integer < n_distance && integer_vars[next_integer] == j) { + const i_t row = base.num_rows + 2 * next_integer++; + matrix.i[out] = row; + matrix.x[out++] = f_t{1}; + matrix.i[out] = row + 1; + matrix.x[out++] = f_t{-1}; + } + } + for (i_t k = 0; k < n_distance; ++k) { + matrix.col_start[base.num_cols + k] = out; + const i_t row = base.num_rows + 2 * k; + matrix.i[out] = row; + matrix.x[out++] = f_t{-1}; + matrix.i[out] = row + 1; + matrix.x[out++] = f_t{-1}; + } + matrix.col_start[result.num_cols] = out; + cuopt_assert(out == base_nnz + 4 * n_distance, "distance problem nonzero count mismatch"); + result.A = std::move(matrix); + return result; +} constexpr int32_t fj_bound_prop_rounds = 10; // A deduction is committed only when it moves a bound by more than this many absolute tolerances. @@ -2757,105 +2883,102 @@ static void apply_bound_propagation(fj_cpu_climber_t& fj_cpu) fj_cpu.n_binary_vars + fj_cpu.n_integer_vars); } -// Rounds the LP relaxation into this lane's start point. Solved by dual simplex on the lane's own -// thread rather than during portfolio construction, so the other seven start searching immediately. +// A bounded feasibility pump for the LP lane, run on the lane's own thread. An integral-feasible +// projection is published; otherwise FJ starts from the least violated rounding the pump saw. template -static void apply_lp_rounded_seed(fj_cpu_climber_t& fj_cpu) +static void apply_lp_rounded_seed(fj_cpu_climber_t& fj_cpu, f_t lane_time_limit) { if (!fj_cpu.use_lp_seed || fj_cpu.pb_ptr == nullptr) return; if (fj_cpu.view.pb.nnz > fj_lp_seed_nnz_limit) return; - simplex::user_problem_t relaxation(fj_cpu.pb_ptr->handle_ptr); - fj_cpu.pb_ptr->get_host_user_problem(relaxation); + const double budget = + std::min(fj_lp_pump_max_budget_s, fj_lp_pump_budget_share * (double)lane_time_limit); + if (budget <= 0) return; - simplex_solver_settings_t lp_settings; - lp_settings.relaxation = true; - lp_settings.time_limit = fj_lp_seed_budget_s; - lp_settings.log.log = false; + simplex::user_problem_t base(fj_cpu.pb_ptr->handle_ptr); + fj_cpu.pb_ptr->get_host_user_problem(base); - std::vector relaxed; - simplex::lp_status_t lp_status = simplex::lp_status_t::UNSET; - double lp_seconds = 0; + const auto started = std::chrono::steady_clock::now(); + const i_t n_variables = fj_cpu.view.pb.n_variables; - // solve_linear_program_advanced rather than simplex::solve, whose collapsed int return cannot - // separate a limit -- which leaves a usable vertex behind -- from infeasibility, which does not. - // Guarded on f_t because dual simplex is only instantiated for double. - if constexpr (std::is_same_v) { - const f_t lp_start = tic(); - lp_problem_t converted(relaxation.handle_ptr, - relaxation.num_rows, - relaxation.num_cols, - relaxation.A.col_start[relaxation.A.n]); - std::vector new_slacks; - simplex::dualize_info_t dualize_info; - simplex::convert_user_problem(relaxation, lp_settings, converted, new_slacks, dualize_info); + std::vector rounded; + std::vector selected; + f_t selected_violation = -std::numeric_limits::infinity(); - simplex::lp_solution_t lp_solution(converted.num_rows, converted.num_cols); - std::vector vstatus; - std::vector edge_norms; - lp_status = simplex::solve_linear_program_advanced( - converted, lp_start, lp_settings, lp_solution, vstatus, edge_norms); - relaxed = lp_solution.x; - lp_seconds = toc(lp_start); - } + for (int32_t projection = 0; projection < fj_lp_pump_projections; ++projection) { + const double remaining = + budget - std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + if (remaining <= 0) break; - // A vertex reached at a limit is dual feasible and still worth rounding. The remaining - // terminations leave nothing to round. - const bool usable = lp_status == simplex::lp_status_t::OPTIMAL || - lp_status == simplex::lp_status_t::TIME_LIMIT || - lp_status == simplex::lp_status_t::ITERATION_LIMIT || - lp_status == simplex::lp_status_t::CONCURRENT_LIMIT || - lp_status == simplex::lp_status_t::WORK_LIMIT; - CUOPT_LOG_DEBUG("%sCPUFJ LP seed: %s after %.3fs of %.3fs%s", - fj_cpu.log_prefix.c_str(), - simplex::lp_status_to_string(lp_status).c_str(), - lp_seconds, - fj_lp_seed_budget_s, - usable ? "" : ", discarded"); - if (!usable) return; + // Projection 0 is the plain relaxation; the rest chase the previous rounding. + const auto distance = projection == 0 ? simplex::user_problem_t(base.handle_ptr) + : make_lp_distance_problem(base, fj_cpu, rounded); + const auto& relaxation = projection == 0 ? base : distance; - const i_t n_variables = fj_cpu.view.pb.n_variables; - // convert_user_problem appends slacks, so the model's own variables are the leading columns. - cuopt_assert((i_t)relaxed.size() >= n_variables, "dual simplex returned too few columns"); + std::vector x; + if (!solve_lp_relaxation(relaxation, remaining, x)) break; + // convert_user_problem appends slacks, so the model's own variables are the leading columns. + if ((i_t)x.size() < n_variables) break; - std::vector candidate(n_variables); - for (i_t var = 0; var < n_variables; ++var) { - cuopt_assert(isfinite(relaxed[var]), "dual simplex returned a non-finite value"); - const auto bounds = fj_cpu.h_var_bounds[var].get(); - const f_t lower = get_lower(bounds); - const f_t upper = get_upper(bounds); - f_t value = std::clamp(relaxed[var], lower, upper); - if (is_integer_var(fj_cpu, var)) { - value = round(value); - // Rounding can leave the bounds, and a variable with no integral value inside them cannot be - // seeded at all without breaking the engine's integrality invariant. - if (value < lower || value > upper) return; + rounded.resize(n_variables); + cuopt::pcgenerator_t rng(fj_cpu.settings.seed); + bool valid = true; + for (i_t var = 0; var < n_variables && valid; ++var) { + const auto bounds = fj_cpu.h_var_bounds[var].get(); + const f_t lower = get_lower(bounds); + const f_t upper = get_upper(bounds); + f_t value = std::clamp(x[var], lower, upper); + if (!isfinite(value)) { + valid = false; + break; + } + if (is_integer_var(fj_cpu, var)) { + // Rounded up with probability equal to the fractional part, so successive projections of + // the same point explore different corners. + const f_t fraction = value - floor(value); + value = rng.next_double() < fraction ? ceil(value) : floor(value); + // A variable with no integral value inside its bounds cannot be seeded at all without + // breaking the engine's integrality invariant. + valid = value >= lower && value <= upper; + } + rounded[var] = value; } - candidate[var] = value; - } - - fj_cpu.h_assignment = candidate; - fj_cpu.h_best_assignment = candidate; - recompute_lhs(fj_cpu); + if (!valid) break; - // The rounded point can already be integral-feasible. It never passed through apply_move, so the - // incumbent is recorded here through the same contract that path uses. - if (fj_cpu.violated_constraints.empty() && check_variable_feasibility(fj_cpu)) { - fj_cpu.h_best_objective = - fj_cpu.h_incumbent_objective - fj_cpu.settings.parameters.breakthrough_move_epsilon; - fj_cpu.feasible_found = true; - CUOPT_LOG_DEBUG("%sCPUFJ new incumbent: objective %.17g", - fj_cpu.log_prefix.c_str(), - fj_cpu.h_best_objective); - if (fj_cpu.improvement_callback) { - fj_cpu.improvement_callback(fj_cpu.h_incumbent_objective, - fj_cpu.h_assignment, - fj_cpu.work_units_elapsed.load(std::memory_order_acquire)); + fj_cpu.h_assignment = rounded; + recompute_lhs(fj_cpu); + // total_violations sums a non-positive excess, so the greater value is the closer point. + if (fj_cpu.total_violations > selected_violation) { + selected_violation = fj_cpu.total_violations; + selected = rounded; } - if (fj_cpu.shared_incumbent) { - fj_cpu.shared_incumbent->publish(fj_cpu.h_incumbent_objective, fj_cpu.h_assignment); + + // The rounded point can already be integral-feasible. It never passed through apply_move, so + // the incumbent is recorded here through the same contract that path uses. + if (fj_cpu.violated_constraints.empty() && check_variable_feasibility(fj_cpu)) { + fj_cpu.h_best_assignment = rounded; + fj_cpu.h_best_objective = + fj_cpu.h_incumbent_objective - fj_cpu.settings.parameters.breakthrough_move_epsilon; + fj_cpu.feasible_found = true; + CUOPT_LOG_DEBUG("%sCPUFJ new incumbent: objective %.17g", + fj_cpu.log_prefix.c_str(), + fj_cpu.h_best_objective); + if (fj_cpu.improvement_callback) { + fj_cpu.improvement_callback(fj_cpu.h_incumbent_objective, + fj_cpu.h_assignment, + fj_cpu.work_units_elapsed.load(std::memory_order_acquire)); + } + if (fj_cpu.shared_incumbent) { + fj_cpu.shared_incumbent->publish(fj_cpu.h_incumbent_objective, fj_cpu.h_assignment); + } + return; } } + + if (selected.empty()) return; + fj_cpu.h_assignment = selected; + fj_cpu.h_best_assignment = selected; + recompute_lhs(fj_cpu); } template @@ -2865,15 +2988,19 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w // Precedes the dispatch below because a variable it squeezes to [0,1] can bring the whole model // into the binary engine's shape. apply_bound_propagation(*fj_cpu); - const f_t prop_seconds = - fj_cpu->use_bound_prop + // Also ahead of the dispatch, so an all-binary model gets the same LP-derived start. + apply_lp_rounded_seed(*fj_cpu, in_time_limit); + + const bool paid_setup = fj_cpu->use_bound_prop || fj_cpu->use_lp_seed; + const f_t setup_seconds = + paid_setup ? std::chrono::duration(std::chrono::high_resolution_clock::now() - solve_start).count() : f_t{0}; + const f_t remaining = std::max(f_t{0}, in_time_limit - setup_seconds); + if (remaining <= f_t{0}) return; // problem fits the binary fastpath shape? run it (engine is solve-local) - if (try_cpufj_binary_solve(*fj_cpu, in_time_limit - prop_seconds, work_unit_limit)) return; - - apply_lp_rounded_seed(*fj_cpu); + if (try_cpufj_binary_solve(*fj_cpu, remaining, work_unit_limit)) return; i_t local_mins = 0; // The LP comes out of this lane's own budget; every other lane's clock starts where it did. @@ -4147,12 +4274,11 @@ void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, i const f_t obj_weight_ladder[4] = {0, 4, 32, 0}; const f_t obj_weight_floor[4] = {1, 4, 32, 1}; - // One structural start per lane; lanes 0 and 4 keep the shared anchor. Lane 7 also keeps it here, - // because its replacement is an LP solved inside that lane's own task rather than in setup. - climber.use_lp_seed = lane % 8 == 7; + // One structural start per lane; lanes 0, 4 and 7 keep the shared anchor here. Lane 4's is + // replaced inside its own task by the LP pump, so construction does not wait on an LP. + climber.use_lp_seed = lane % 8 == 4; - // Half the portfolio searches the propagated model, half the model as parsed. Off the LP lane, so - // one lane does not carry both setup passes. + // Half the portfolio searches the propagated model, half the model as parsed. climber.use_bound_prop = lane % 2 == 0; climber.use_weight_donation = (lane % 8 == 5) || (lane % 8 == 6); From b6f4f43625ff9e9046fecf1b60d16948d992fec7 Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 24 Aug 2026 12:44:48 -0700 Subject: [PATCH 74/78] cpufj batching --- .../feasibility_jump_impl_common.cuh | 5 +- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 344 +++++++++++++++++- .../feasibility_jump/fj_cpu.cuh | 26 ++ .../feasibility_jump/fj_cpu_binary.cu | 2 +- cpp/src/utilities/macros.cuh | 2 +- 5 files changed, 370 insertions(+), 9 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh index 046e138c5b..6535794e08 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh @@ -196,7 +196,6 @@ HDI f_t get_breakthrough_move(typename fj_t::climber_data_t::view_t fj auto bounds = fj.pb.variable_bounds[var_idx]; f_t v_lb = get_lower(bounds); f_t v_ub = get_upper(bounds); - cuopt_assert(isfinite(v_lb) || isfinite(v_ub), "unexpected free variable"); cuopt_assert(v_lb <= v_ub, "invalid bounds"); cuopt_assert(fj.pb.check_variable_within_bounds(var_idx, fj.incumbent_assignment[var_idx]), "invalid incumbent assignment"); @@ -220,10 +219,12 @@ HDI f_t get_breakthrough_move(typename fj_t::climber_data_t::view_t fj new_val = old_val + delta_ij; } - // fallback + // A positive coefficient gives a negative delta, so only the lower bound can be the one broken, + // and a broken bound is finite. Free and half-free variables therefore land here finite too. if (!fj.pb.check_variable_within_bounds(var_idx, new_val)) { new_val = obj_coeff > 0 ? v_lb : v_ub; } + cuopt_assert(isfinite(new_val), "breakthrough move left the representable range"); return new_val; } diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 360c9da6ab..4f1500031b 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -160,7 +160,8 @@ std::pair feas_score_constraint(fj_cpu_climber_t& fj_cpu, { const auto& fj = fj_cpu.view; cuopt_assert(isfinite(delta), "invalid delta"); - cuopt_assert(cstr_coeff != 0 && isfinite(cstr_coeff), "invalid coefficient"); + // A model may store explicit zeros, and a zero coefficient contributes nothing to the row. + cuopt_assert(isfinite(cstr_coeff), "invalid coefficient"); f_t base_feas = 0; f_t bonus_robust = 0; @@ -385,6 +386,11 @@ static void precompute_problem_features(fj_cpu_climber_t& fj_cpu) fj_cpu.problem_density = (double)total_nnz / ((double)n_vars * n_cstrs); } +// Greedy first-fit colouring of the variable co-occurrence graph, where each row is a clique. The +// adjacency is walked per variable and never stored: the clique expansion is far larger than nnz. +template +static void compute_variable_coloring(fj_cpu_climber_t& fj_cpu); + template static void log_regression_features(fj_cpu_climber_t& fj_cpu, double time_window_ms, @@ -548,6 +554,207 @@ static inline std::pair range_for_constraint(fj_cpu_climber_t +static void compute_variable_coloring(fj_cpu_climber_t& fj_cpu) +{ + const i_t n_vars = fj_cpu.view.pb.n_variables; + const i_t n_cstrs = fj_cpu.view.pb.n_constraints; + + i_t max_row_length = 0; + double clique_edges = 0; + for (i_t row = 0; row < n_cstrs; ++row) { + const i_t length = fj_cpu.h_offsets[row + 1] - fj_cpu.h_offsets[row]; + max_row_length = std::max(max_row_length, length); + if (length > 1) clique_edges += (double)length * (length - 1) / 2.0; + } + if (n_vars <= 0 || max_row_length <= 0) return; + + const double class_size = (double)n_vars / max_row_length; + const double edges_per_nnz = clique_edges / std::max(1, (double)fj_cpu.view.pb.nnz); + if (class_size < fj_batch_min_class_size || edges_per_nnz > fj_batch_max_edges_per_nnz) { + CUOPT_LOG_DEBUG("CPUFJ move batching declined: class size %.2f, clique edges/nnz %.2f", + class_size, + edges_per_nnz); + return; + } + + const auto started = std::chrono::steady_clock::now(); + fj_cpu.h_var_color.assign(n_vars, -1); + fj_cpu.n_colors = 0; + std::vector neighbor_stamp(n_vars, -1); + std::vector color_stamp(n_vars, -1); + + for (i_t var = 0; var < n_vars; ++var) { + const auto [rev_begin, rev_end] = reverse_range_for_var(fj_cpu, var); + for (i_t p = rev_begin; p < rev_end; ++p) { + const auto [begin, end] = + range_for_constraint(fj_cpu, fj_cpu.h_reverse_constraints[p]); + for (i_t k = begin; k < end; ++k) { + const i_t other = fj_cpu.h_variables[k]; + if (other == var || neighbor_stamp[other] == var) continue; + neighbor_stamp[other] = var; + const i_t taken = fj_cpu.h_var_color[other]; + if (taken >= 0) color_stamp[taken] = var; + } + } + + i_t color = 0; + while (color < fj_cpu.n_colors && color_stamp[color] == var) ++color; + if (color == fj_cpu.n_colors) ++fj_cpu.n_colors; + fj_cpu.h_var_color[var] = color; + } + + fj_cpu.h_var_best_score.assign(n_vars, fj_staged_score_t::invalid()); + fj_cpu.h_var_best_delta.assign(n_vars, f_t{0}); + fj_cpu.h_var_best_stamp.assign(n_vars, 0); + fj_cpu.h_var_best_rowsum.assign(n_vars, 0); + fj_cpu.h_var_bucket_stamp.assign(n_vars, 0); + fj_cpu.batch_size_hist.assign(fj_batch_hist_bins, 0); + fj_cpu.h_color_candidates.assign(fj_cpu.n_colors, {}); + fj_cpu.h_color_epoch.assign(fj_cpu.n_colors, 0); + fj_cpu.var_best_epoch = 1; + + CUOPT_LOG_DEBUG("CPUFJ move batching: %d colours over %d variables in %.3f ms", + fj_cpu.n_colors, + n_vars, + std::chrono::duration(std::chrono::steady_clock::now() - + started) + .count()); +} + +// Sum of the versions of the rows a variable appears in. Versions only ever increase, so an +// unchanged sum means no incident row has been touched. +template +static inline int64_t incident_row_version_sum(fj_cpu_climber_t& fj_cpu, i_t var_idx) +{ + const auto [begin, end] = reverse_range_for_var(fj_cpu, var_idx); + int64_t sum = 0; + for (i_t p = begin; p < end; ++p) + sum += fj_cpu.h_cstr_version[fj_cpu.h_reverse_constraints[p]]; + return sum; +} + +// Records a candidate move for its variable. The table keeps a best per variable, independent of +// the argmax the caller is tracking, which is what lets a batch be assembled later. +template +static inline void record_var_best_move(fj_cpu_climber_t& fj_cpu, + i_t var_idx, + fj_staged_score_t score, + f_t delta) +{ + if (!fj_cpu.use_move_batching) return; + if (!(score > fj_staged_score_t::zero())) return; + + const bool current = fj_cpu.h_var_best_stamp[var_idx] == fj_cpu.var_best_epoch; + if (current && !(score > fj_cpu.h_var_best_score[var_idx])) return; + + fj_cpu.h_var_best_score[var_idx] = score; + fj_cpu.h_var_best_delta[var_idx] = delta; + fj_cpu.h_var_best_stamp[var_idx] = fj_cpu.var_best_epoch; + fj_cpu.h_var_best_rowsum[var_idx] = incident_row_version_sum(fj_cpu, var_idx); + + const i_t color = fj_cpu.h_var_color[var_idx]; + cuopt_assert(color >= 0 && color < fj_cpu.n_colors, "variable has no colour"); + if (fj_cpu.h_color_epoch[color] != fj_cpu.var_best_epoch) { + fj_cpu.h_color_candidates[color].clear(); + fj_cpu.h_color_epoch[color] = fj_cpu.var_best_epoch; + } + if (fj_cpu.h_var_bucket_stamp[var_idx] == fj_cpu.var_best_epoch) return; + fj_cpu.h_var_bucket_stamp[var_idx] = fj_cpu.var_best_epoch; + fj_cpu.h_color_candidates[color].push_back(var_idx); +} + +// Retires the whole table in constant time. Called wherever the weights or the assignment move far +// enough that every cached score is suspect. +template +static inline void retire_var_best_moves(fj_cpu_climber_t& fj_cpu) +{ + if (!fj_cpu.use_move_batching) return; + ++fj_cpu.var_best_epoch; +} + +// Companions per batch attempt, as min, median, max and mean. A median landing in the saturating +// last bin reads as that bin's index, and max_batch_size carries the true tail. +template +static void log_batch_distribution(const fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.n_batch_attempts == 0) return; + + int32_t smallest = -1; + int32_t median = -1; + int64_t seen = 0; + for (size_t bin = 0; bin < fj_cpu.batch_size_hist.size(); ++bin) { + if (fj_cpu.batch_size_hist[bin] == 0) continue; + if (smallest < 0) smallest = (int32_t)bin; + seen += fj_cpu.batch_size_hist[bin]; + if (median < 0 && 2 * seen > fj_cpu.n_batch_attempts) median = (int32_t)bin; + } + + CUOPT_LOG_DEBUG( + "%sCPUFJ batch companions: min %d median %d max %lld mean %.3f over %lld attempts, %lld total, " + "%d colours, batching %s", + fj_cpu.log_prefix.c_str(), + smallest, + median, + (long long)fj_cpu.max_batch_size, + (double)fj_cpu.n_batched_moves / (double)fj_cpu.n_batch_attempts, + (long long)fj_cpu.n_batch_attempts, + (long long)fj_cpu.n_batched_moves, + fj_cpu.n_colors, + fj_cpu.use_move_batching ? "on" : "off"); +} + +// Companions for the chosen move: same colour, so they share no row with it or with each other and +// their recorded scores and deltas hold as the batch is applied. Excludes the chosen move itself. +template +static void collect_move_batch(fj_cpu_climber_t& fj_cpu, + fj_move_t chosen, + std::vector& batch) +{ + batch.clear(); + if (!fj_cpu.use_move_batching) return; + + const i_t color = fj_cpu.h_var_color[chosen.var_idx]; + cuopt_assert(color >= 0 && color < fj_cpu.n_colors, "chosen move has no colour"); + if (fj_cpu.h_color_epoch[color] != fj_cpu.var_best_epoch) return; + + for (i_t var_idx : fj_cpu.h_color_candidates[color]) { + if (var_idx == chosen.var_idx) continue; + if (fj_cpu.h_var_best_stamp[var_idx] != fj_cpu.var_best_epoch) continue; + if (!(fj_cpu.h_var_best_score[var_idx] > fj_staged_score_t::zero())) continue; + if (fj_cpu.h_var_best_rowsum[var_idx] != incident_row_version_sum(fj_cpu, var_idx)) + continue; + + batch.push_back({var_idx, fj_cpu.h_var_best_delta[var_idx]}); + // Invalidated so a second pass over the bucket cannot apply the move twice. + fj_cpu.h_var_best_stamp[var_idx] = 0; + } + + ++fj_cpu.n_batch_attempts; + fj_cpu.n_batched_moves += (int64_t)batch.size(); + ++fj_cpu.batch_size_hist[std::min(batch.size(), fj_cpu.batch_size_hist.size() - 1)]; + if ((int64_t)batch.size() > fj_cpu.max_batch_size) + fj_cpu.max_batch_size = (int64_t)batch.size(); + if (fj_cpu.n_batch_attempts == fj_batch_probe_attempts && + (double)fj_cpu.n_batched_moves < fj_batch_min_yield * (double)fj_batch_probe_attempts) { + fj_cpu.use_move_batching = false; + CUOPT_LOG_DEBUG("%sCPUFJ move batching off: %lld companions over %lld attempts", + fj_cpu.log_prefix.c_str(), + (long long)fj_cpu.n_batched_moves, + (long long)fj_cpu.n_batch_attempts); + } +} + template static inline bool check_variable_within_bounds(fj_cpu_climber_t& fj_cpu, i_t var_idx, @@ -559,6 +766,69 @@ static inline bool check_variable_within_bounds(fj_cpu_climber_t& fj_c return within_bounds; } +// Names the first variable whose assignment sits outside its own bounds, so the writer that left it +// there is identified by the call site. Scans, and is only reached through cuopt_func_call. +template +static void audit_assignment_bounds(fj_cpu_climber_t& fj_cpu, const char* site) +{ + for (i_t var = 0; var < fj_cpu.view.pb.n_variables; ++var) { + const f_t val = fj_cpu.h_assignment[var]; + auto bounds = fj_cpu.h_var_bounds[var].get(); + const bool inbox = fj_cpu.view.pb.check_variable_within_bounds(var, val); + const bool integral = + var_t::INTEGER != fj_cpu.h_var_types[var] || fj_cpu.view.pb.is_integer(val); + if (inbox && integral) continue; + + // stderr and flushed, so the abort below cannot swallow it. + std::fprintf(stderr, + "%sCPUFJ %s left var %d at %.17g outside [%.17g, %.17g], integer %d\n", + fj_cpu.log_prefix.c_str(), + site, + (int)var, + (double)val, + (double)get_lower(bounds), + (double)get_upper(bounds), + (int)(var_t::INTEGER == fj_cpu.h_var_types[var])); + std::fflush(stderr); + cuopt_assert(false, "assignment left the variable bounds"); + return; + } +} + +// Reports the first objective variable get_breakthrough_move would reject, reading the value both +// from the climber's vector and through the view span so a bad value is told from a stale span. +template +static void audit_breakthrough_inputs(fj_cpu_climber_t& fj_cpu) +{ + for (auto var_idx : fj_cpu.h_objective_vars) { + const f_t viewed = fj_cpu.view.incumbent_assignment[var_idx]; + if (fj_cpu.view.pb.check_variable_within_bounds(var_idx, viewed)) continue; + + const f_t direct = fj_cpu.h_assignment[var_idx]; + auto bounds = fj_cpu.h_var_bounds[var_idx].get(); + auto viewed_bnd = fj_cpu.view.pb.variable_bounds[var_idx]; + // stderr and flushed, so the abort below cannot swallow it. + std::fprintf(stderr, + "%sCPUFJ breakthrough input var %d: direct %.17g viewed %.17g nan %d, bounds " + "direct [%.17g, %.17g] viewed [%.17g, %.17g], obj %.17g, degree %d, integer %d\n", + fj_cpu.log_prefix.c_str(), + (int)var_idx, + (double)direct, + (double)viewed, + (int)(viewed != viewed), + (double)get_lower(bounds), + (double)get_upper(bounds), + (double)get_lower(viewed_bnd), + (double)get_upper(viewed_bnd), + (double)fj_cpu.h_obj_coeffs[var_idx], + (int)(fj_cpu.h_reverse_offsets[var_idx + 1] - fj_cpu.h_reverse_offsets[var_idx]), + (int)(var_t::INTEGER == fj_cpu.h_var_types[var_idx])); + std::fflush(stderr); + cuopt_assert(false, "breakthrough move input out of bounds"); + return; + } +} + template static inline bool is_integer_var(fj_cpu_climber_t& fj_cpu, i_t var_idx) { @@ -639,6 +909,8 @@ static inline std::pair compute_score(fj_cpu_climber_t(fj_cpu, @@ -1043,6 +1315,8 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) raft::random::PCGenerator rng(fj_cpu.settings.seed + fj_cpu.iterations, 0, 0); bool smoothing = rng.next_float() <= fj_cpu.settings.parameters.weight_smoothing_probability; + retire_var_best_moves(fj_cpu); + if (smoothing) { smooth_weights(fj_cpu); return; @@ -1210,6 +1484,9 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, // update the assignment and objective proper fj_cpu.h_assignment[var_idx] = new_val; + // The clamp above passes a NaN straight through, and every comparison against one is false. + cuopt_assert(fj_cpu.view.pb.check_variable_within_bounds(var_idx, new_val), + "apply_move left the variable bounds"); // Kahan compensated summation, as for h_lhs. The incumbent objective is reported as-is, so it // cannot carry the drift of a long uncompensated chain of deltas. @@ -1248,6 +1525,8 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, fj_cpu.h_objective_weight = min((f_t)fj_obj_weight_incumbent_cap, fj_cpu.h_objective_weight + (f_t)fj_obj_weight_incumbent_bump); + // The weight enters every score, and row versions cannot see it move. + retire_var_best_moves(fj_cpu); } } @@ -1438,6 +1717,7 @@ static thrust::tuple find_mtm_move( // reject this move if it would increase the target variable to a numerically unstable value if (fj_cpu.view.move_numerically_stable( val, new_val, infeasibility, fj_cpu.total_violations)) { + record_var_best_move(fj_cpu, var_idx, score, delta); if (best_score < score) { best_score = score; best_move = move; @@ -1451,6 +1731,7 @@ static thrust::tuple find_mtm_move( fj_cpu.h_best_objective < std::numeric_limits::infinity() && fj_cpu.h_incumbent_objective >= fj_cpu.h_best_objective + fj_cpu.settings.parameters.breakthrough_move_epsilon) { + cuopt_func_call(audit_breakthrough_inputs(fj_cpu)); for (auto var_idx : fj_cpu.h_objective_vars) { f_t old_val = fj_cpu.h_assignment[var_idx]; f_t new_val = get_breakthrough_move(fj_cpu.view, var_idx); @@ -1473,6 +1754,7 @@ static thrust::tuple find_mtm_move( if (fj_cpu.view.move_numerically_stable( old_val, new_val, infeasibility, fj_cpu.total_violations)) { + record_var_best_move(fj_cpu, var_idx, score, delta); if (best_score < score) { best_score = score; best_move = move; @@ -1880,6 +2162,7 @@ static void perturb(fj_cpu_climber_t& fj_cpu) fj_cpu.h_assignment = fj_cpu.h_best_assignment; if (fj_cpu.shared_incumbent) { fj_cpu.shared_incumbent->adopt(fj_cpu.h_best_objective, fj_cpu.h_assignment); + cuopt_func_call(audit_assignment_bounds(fj_cpu, "shared adopt")); } } @@ -1897,6 +2180,7 @@ static void perturb(fj_cpu_climber_t& fj_cpu) ++fj_cpu.n_lhs_recompute_perturb; recompute_lhs(fj_cpu); + retire_var_best_moves(fj_cpu); } template @@ -1925,6 +2209,7 @@ static void restart_from_infeasible_checkpoint(fj_cpu_climber_t& fj_cp ++fj_cpu.n_lhs_recompute_restart; recompute_lhs(fj_cpu); invalidate_mtm_cache(fj_cpu); + cuopt_func_call(audit_assignment_bounds(fj_cpu, "checkpoint restore")); } // Nonzeros per extra restart window, the cap on that, and how many windows a lane waits. @@ -1974,6 +2259,7 @@ static void track_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) invalidate_mtm_cache(fj_cpu); reset_infeasible_checkpoint(fj_cpu); fj_cpu.restores_since_improvement = 0; + cuopt_func_call(audit_assignment_bounds(fj_cpu, "randomized restart")); CUOPT_LOG_DEBUG("%sCPUFJ randomized restart at iteration %d", fj_cpu.log_prefix.c_str(), @@ -2291,6 +2577,11 @@ void finalize_fj_cpu_host_initialization( fj_cpu.h_objective_vars.resize(end - fj_cpu.h_objective_vars.begin()); fj_cpu.view.objective_vars = raft::device_span(fj_cpu.h_objective_vars.data(), fj_cpu.h_objective_vars.size()); + // get_breakthrough_move divides by the coefficient of every variable in here. + for (auto var_idx : fj_cpu.h_objective_vars) { + cuopt_assert(fj_cpu.h_obj_coeffs[var_idx] != f_t{0}, "null coefficient in the objective vars"); + cuopt_assert(isfinite((f_t)fj_cpu.h_obj_coeffs[var_idx]), "non-finite objective coefficient"); + } f_t abs_obj_sum = 0; for (auto var_idx : fj_cpu.h_objective_vars) { @@ -2332,6 +2623,7 @@ void finalize_fj_cpu_host_initialization( // Precompute static problem features for regression model precompute_problem_features(fj_cpu); + compute_variable_coloring(fj_cpu); } template @@ -2372,6 +2664,21 @@ static void finalize_fj_cpu_host_initialization_from_template( fj_cpu.h_incumbent_objective = tmpl.h_incumbent_objective; fj_cpu.h_objective_sumcomp = tmpl.h_objective_sumcomp; + // The colouring is structural, so it carries over; the score table is this climber's own. + fj_cpu.h_var_color = tmpl.h_var_color; + fj_cpu.n_colors = tmpl.n_colors; + if (fj_cpu.n_colors > 0) { + fj_cpu.h_var_best_score.assign(n_variables, fj_staged_score_t::invalid()); + fj_cpu.h_var_best_delta.assign(n_variables, f_t{0}); + fj_cpu.h_var_best_stamp.assign(n_variables, 0); + fj_cpu.h_var_best_rowsum.assign(n_variables, 0); + fj_cpu.h_var_bucket_stamp.assign(n_variables, 0); + fj_cpu.batch_size_hist.assign(fj_batch_hist_bins, 0); + fj_cpu.h_color_candidates.assign(fj_cpu.n_colors, {}); + fj_cpu.h_color_epoch.assign(fj_cpu.n_colors, 0); + fj_cpu.var_best_epoch = 1; + } + fj_cpu.n_binary_vars = tmpl.n_binary_vars; fj_cpu.n_integer_vars = tmpl.n_integer_vars; fj_cpu.avg_var_degree = tmpl.avg_var_degree; @@ -2503,6 +2810,14 @@ static std::unique_ptr> init_fj_cpu_from_host_lp( template static void sanity_checks(fj_cpu_climber_t& fj_cpu) { + // Assigning any of these wrappers from a plain vector rebinds its buffer and strands the span. + cuopt_assert(fj_cpu.view.incumbent_assignment.data() == fj_cpu.h_assignment.data(), + "incumbent_assignment span no longer covers h_assignment"); + cuopt_assert(fj_cpu.view.incumbent_lhs.data() == fj_cpu.h_lhs.data(), + "incumbent_lhs span no longer covers h_lhs"); + cuopt_assert(fj_cpu.view.pb.variable_bounds.data() == fj_cpu.h_var_bounds.data(), + "variable_bounds span no longer covers h_var_bounds"); + // Check that each variable is within its bounds for (i_t var_idx = 0; var_idx < fj_cpu.view.pb.n_variables; ++var_idx) { f_t val = fj_cpu.h_assignment[var_idx]; @@ -2874,6 +3189,7 @@ static void apply_bound_propagation(fj_cpu_climber_t& fj_cpu) raft::device_span(fj_cpu.h_binary_indices.data(), fj_cpu.h_binary_indices.size()); if (clamped) recompute_lhs(fj_cpu); + cuopt_func_call(audit_assignment_bounds(fj_cpu, "bound prop")); CUOPT_LOG_DEBUG("%sCPUFJ bound prop: %d passes, %d domains tightened, %d binary of %d integer", fj_cpu.log_prefix.c_str(), @@ -2945,7 +3261,9 @@ static void apply_lp_rounded_seed(fj_cpu_climber_t& fj_cpu, f_t lane_t } if (!valid) break; - fj_cpu.h_assignment = rounded; + // Copied in place: assigning the wrapper from a plain vector rebinds its buffer and leaves the + // incumbent_assignment span on freed memory. + std::copy(rounded.begin(), rounded.end(), fj_cpu.h_assignment.begin()); recompute_lhs(fj_cpu); // total_violations sums a non-positive excess, so the greater value is the closer point. if (fj_cpu.total_violations > selected_violation) { @@ -2956,7 +3274,7 @@ static void apply_lp_rounded_seed(fj_cpu_climber_t& fj_cpu, f_t lane_t // The rounded point can already be integral-feasible. It never passed through apply_move, so // the incumbent is recorded here through the same contract that path uses. if (fj_cpu.violated_constraints.empty() && check_variable_feasibility(fj_cpu)) { - fj_cpu.h_best_assignment = rounded; + std::copy(rounded.begin(), rounded.end(), fj_cpu.h_best_assignment.begin()); fj_cpu.h_best_objective = fj_cpu.h_incumbent_objective - fj_cpu.settings.parameters.breakthrough_move_epsilon; fj_cpu.feasible_found = true; @@ -2976,9 +3294,10 @@ static void apply_lp_rounded_seed(fj_cpu_climber_t& fj_cpu, f_t lane_t } if (selected.empty()) return; - fj_cpu.h_assignment = selected; - fj_cpu.h_best_assignment = selected; + std::copy(selected.begin(), selected.end(), fj_cpu.h_assignment.begin()); + std::copy(selected.begin(), selected.end(), fj_cpu.h_best_assignment.begin()); recompute_lhs(fj_cpu); + cuopt_func_call(audit_assignment_bounds(fj_cpu, "lp pump")); } template @@ -3003,6 +3322,7 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w if (try_cpufj_binary_solve(*fj_cpu, remaining, work_unit_limit)) return; i_t local_mins = 0; + std::vector batch_moves; // The LP comes out of this lane's own budget; every other lane's clock starts where it did. auto loop_start = (fj_cpu->use_lp_seed || fj_cpu->use_bound_prop) ? solve_start @@ -3128,6 +3448,13 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w } if (score > fj_staged_score_t::zero() && !should_perturb) { + // A 2-opt lift already commits two coupled moves, and its second half is scored against the + // state before both, so it stays on its own. + if (lift_companion.var_idx < 0) { + collect_move_batch(*fj_cpu, move, batch_moves); + for (const auto& batched : batch_moves) + apply_move(*fj_cpu, batched.var_idx, batched.value, false); + } apply_move(*fj_cpu, move.var_idx, move.value, false); if (lift_companion.var_idx >= 0) { apply_move(*fj_cpu, lift_companion.var_idx, lift_companion.value, false); @@ -3226,6 +3553,7 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w (long long)fj_cpu->n_checkpoint_restores, (long long)fj_cpu->n_checkpoint_snapshots, fj_cpu->max_restores_since_improvement); + log_batch_distribution(*fj_cpu); #if CPUFJ_TIMING_TRACE // Print final timing statistics @@ -4282,6 +4610,12 @@ void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, i climber.use_bound_prop = lane % 2 == 0; climber.use_weight_donation = (lane % 8 == 5) || (lane % 8 == 6); + + // Only where the colouring came out; n_colors is zero when the structure declined it. + climber.use_move_batching = + climber.n_colors > 0 && ((lane % 8 == 2) || (lane % 8 == 6)); + climber.use_move_batching = true; + if (climber.n_colors == 0) climber.use_move_batching = false; switch (lane % 8) { case 1: apply_lock_weighted_seed(climber); break; case 2: apply_aggressive_constraint_seed(climber); break; diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index ceddadbbdc..df86602d45 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -233,6 +233,32 @@ struct fj_cpu_climber_t { host_contiguous_set_t satisfied_constraints; bool feasible_found{false}; bool trigger_early_lhs_recomputation{false}; + + // Move batching over a colouring of the variable co-occurrence graph, where each row is a clique. + // Same colour means no shared row, so a batch of same-coloured moves has disjoint row support. + bool use_move_batching{false}; + i_t n_colors{0}; + std::vector h_var_color; + // Per variable, the best move seen since the epoch below, and the sum of its incident row + // versions at that moment. The entry is usable while both still match. + std::vector h_var_best_score; + std::vector h_var_best_delta; + std::vector h_var_best_stamp; + std::vector h_var_best_rowsum; + int64_t var_best_epoch{1}; + // Variables that entered the table with a positive score, bucketed by colour. Stale entries are + // skipped at selection, so each bucket carries the epoch it was last cleared in. + std::vector> h_color_candidates; + std::vector h_color_epoch; + // Membership is stamped separately from validity: a variable consumed by a batch is invalidated + // while staying in its bucket, so it cannot be enqueued twice in one epoch. + std::vector h_var_bucket_stamp; + int64_t n_batch_attempts{0}; + int64_t n_batched_moves{0}; + // Companions per attempt, in unit bins. The last bin saturates, so max_batch_size carries the + // tail exactly. + std::vector batch_size_hist; + int64_t max_batch_size{0}; f_t total_violations{0}; // Kahan compensation for total_violations, mirroring h_lhs_sumcomp. Reset wherever the total is // re-derived from the violated set. diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu index b648b8a0b4..7fe3bb1e39 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -329,7 +329,7 @@ static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) [&reverse_offsets, &reverse_constraints](int32_t v) { const auto first = reverse_constraints.begin() + reverse_offsets[v]; const auto last = reverse_constraints.begin() + reverse_offsets[v + 1]; - return thrust::adjacent_find(thrust::host, first, last) == last; + return std::adjacent_find(first, last) == last; }), "duplicate variable in CSR row"); diff --git a/cpp/src/utilities/macros.cuh b/cpp/src/utilities/macros.cuh index d36832015a..380851627f 100644 --- a/cpp/src/utilities/macros.cuh +++ b/cpp/src/utilities/macros.cuh @@ -14,7 +14,7 @@ // 3) heavy #ifdef ASSERT_MODE #include -#define cuopt_assert(val, msg) assert(val&& msg) +#define cuopt_assert(val, msg) assert((val) && msg) #define cuopt_func_call(func) func; #else #define cuopt_assert(val, msg) From c28f2ab6c3263d1852485680ce892327a0e26932 Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 24 Aug 2026 13:26:09 -0700 Subject: [PATCH 75/78] fix Werror=unused flags --- cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 4f1500031b..5e16a8c4b3 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -184,8 +184,10 @@ std::pair feas_score_constraint(fj_cpu_climber_t& fj_cpu, f_t rhs = bounds[bound_idx] * sign; f_t old_lhs = current_lhs * sign; f_t new_lhs = moved_lhs * sign; - f_t old_slack = rhs - old_lhs; - f_t new_slack = rhs - new_lhs; + [[maybe_unused]] + f_t old_slack = rhs - old_lhs; + [[maybe_unused]] + f_t new_slack = rhs - new_lhs; cuopt_assert(isfinite(cstr_weight), "invalid weight"); cuopt_assert(cstr_weight >= 0, "invalid weight"); @@ -1839,8 +1841,6 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) fj_cpu.total_violations_sumcomp = 0; for (i_t cstr_idx = 0; cstr_idx < fj_cpu.view.pb.n_constraints; ++cstr_idx) { auto [offset_begin, offset_end] = range_for_constraint(fj_cpu, cstr_idx); - auto c_lb = fj_cpu.h_cstr_lb[cstr_idx]; - auto c_ub = fj_cpu.h_cstr_ub[cstr_idx]; auto delta_it = thrust::make_transform_iterator(thrust::make_counting_iterator(0), [&fj_cpu](i_t j) { return fj_cpu.h_coefficients[j] * fj_cpu.h_assignment[fj_cpu.h_variables[j]]; @@ -2578,7 +2578,7 @@ void finalize_fj_cpu_host_initialization( fj_cpu.view.objective_vars = raft::device_span(fj_cpu.h_objective_vars.data(), fj_cpu.h_objective_vars.size()); // get_breakthrough_move divides by the coefficient of every variable in here. - for (auto var_idx : fj_cpu.h_objective_vars) { + for ([[maybe_unused]] auto var_idx : fj_cpu.h_objective_vars) { cuopt_assert(fj_cpu.h_obj_coeffs[var_idx] != f_t{0}, "null coefficient in the objective vars"); cuopt_assert(isfinite((f_t)fj_cpu.h_obj_coeffs[var_idx]), "non-finite objective coefficient"); } From 72d73355ddcabe8847581b4598915e234c9821e7 Mon Sep 17 00:00:00 2001 From: yboucher Date: Tue, 25 Aug 2026 03:56:49 -0700 Subject: [PATCH 76/78] match main post rebase --- cpp/src/branch_and_bound/branch_and_bound.cpp | 16 +++------------- cpp/src/branch_and_bound/branch_and_bound.hpp | 8 ++------ 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/cpp/src/branch_and_bound/branch_and_bound.cpp b/cpp/src/branch_and_bound/branch_and_bound.cpp index c532e37918..29174148b7 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.cpp +++ b/cpp/src/branch_and_bound/branch_and_bound.cpp @@ -524,8 +524,7 @@ void branch_and_bound_t::update_user_bound(f_t lower_bound) template bool branch_and_bound_t::set_solution_from_heuristics(const std::vector& solution, - heuristics_origin_t origin, - f_t* solver_objective) + heuristics_origin_t origin) { mutex_original_lp_.lock(); if (solution.size() != original_problem_.num_cols) { @@ -538,7 +537,6 @@ bool branch_and_bound_t::set_solution_from_heuristics(const std::vecto f_t obj = compute_objective(original_lp_, crushed_solution); mutex_original_lp_.unlock(); - if (solver_objective != nullptr) { *solver_objective = obj; } bool is_feasible = false; bool attempt_repair = false; bool success = false; @@ -689,18 +687,10 @@ void branch_and_bound_t::set_solution_from_submip( log_prefix, compute_user_objective(lp, obj)); - // `obj` is in the sub-MIP's own space (fixed variables, own presolve offset), so it cannot be - // handed to solution_callback alongside a user-space assignment. - f_t original_lp_objective = std::numeric_limits::quiet_NaN(); - bool success = - set_solution_from_heuristics(user_sol, heuristics_origin_t::SUBMIP, &original_lp_objective); + bool success = set_solution_from_heuristics(user_sol, heuristics_origin_t::SUBMIP); if (success) { submip_stats.save_success(fixrate); - cuopt_assert(std::isfinite(original_lp_objective), - "SubMIP incumbent objective must be finite when accepted"); - if (settings_.solution_callback != nullptr) { - settings_.solution_callback(user_sol, original_lp_objective); - } + if (settings_.solution_callback != nullptr) { settings_.solution_callback(user_sol, obj); } } } diff --git a/cpp/src/branch_and_bound/branch_and_bound.hpp b/cpp/src/branch_and_bound/branch_and_bound.hpp index 5b95195bb5..7cf5ed3680 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.hpp +++ b/cpp/src/branch_and_bound/branch_and_bound.hpp @@ -132,12 +132,8 @@ class branch_and_bound_t { } } - // Set a solution based on the user problem during the course of the solve. - // When non-null, `solver_objective` receives the objective of `solution` in original_lp_ space, - // which is the space every solution_callback consumer expects. - bool set_solution_from_heuristics(const std::vector& solution, - heuristics_origin_t origin, - f_t* solver_objective = nullptr); + // Set a solution based on the user problem during the course of the solve + bool set_solution_from_heuristics(const std::vector& solution, heuristics_origin_t origin); // Apply a solution found by a CPU FJ worker. void set_solution_from_cpu_fj(f_t obj, const std::vector& assignment, double work_units); From 67ccc3522e95f10e1881327c801ece5e54fd5c8f Mon Sep 17 00:00:00 2001 From: yboucher Date: Tue, 25 Aug 2026 05:19:34 -0700 Subject: [PATCH 77/78] run cpufj portfolio before root LP solve --- cpp/src/branch_and_bound/branch_and_bound.cpp | 50 +++++++- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 108 ++++++++++++++++-- .../feasibility_jump/fj_cpu_worker.cuh | 16 ++- cpp/src/mip_heuristics/mip_constants.hpp | 4 + cpp/src/mip_heuristics/root_heuristics.hpp | 61 +++++++++- 5 files changed, 221 insertions(+), 18 deletions(-) diff --git a/cpp/src/branch_and_bound/branch_and_bound.cpp b/cpp/src/branch_and_bound/branch_and_bound.cpp index 29174148b7..18b45afc19 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.cpp +++ b/cpp/src/branch_and_bound/branch_and_bound.cpp @@ -2430,6 +2430,7 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke f_t work_limit = 1.0; submip_fj_cpu_worker.create_worker(submip_bnb.original_lp_, submip_bnb.var_types_, + submip_bnb.original_problem_.num_cols, initial_guess, submip_bnb.settings_, std::format("{} [CPU FJ]", log_prefix), @@ -2891,6 +2892,7 @@ void branch_and_bound_t::recursive_submip(diving_worker_t* w f_t work_limit = 1.0; submip_fj_cpu_worker.create_worker(worker->leaf_problem, var_types, + original_problem_.num_cols, worker->leaf_solution.x, settings_, std::format("{} [CPU FJ]", log_prefix), @@ -2963,12 +2965,31 @@ void branch_and_bound_t::launch_root_heuristics( f_t work_limit = std::numeric_limits::infinity(); f_t time_limit = settings_.time_limit - toc(exploration_stats_.start_time); + // Odd passes start from the incumbent, even ones from the relaxation. The size guard covers a + // concurrent pass having grown the LP past the crush the incumbent was last taken through. + std::vector fj_seed; + if (cut_pass % 2 == 1) { + mutex_upper_.lock(); + if (incumbent_.has_incumbent && incumbent_.x.size() == (size_t)lp.num_cols) { + fj_seed = incumbent_.x; + } + mutex_upper_.unlock(); + } + if (fj_seed.empty()) { fj_seed = sol; } + current_heuristic->fj_cpu_worker_.improvement_callback = [this](f_t obj, const std::vector& assignment, double work_units) { set_solution_from_cpu_fj(obj, assignment, work_units); }; - current_heuristic->fj_cpu_worker_.create_worker( - lp, var_types_, sol, settings_, "[RootCut CPUFJ] "); + current_heuristic->fj_cpu_worker_.create_worker(lp, + var_types_, + original_problem_.num_cols, + fj_seed, + settings_, + "[RootCut CPUFJ " + std::to_string(cut_pass) + + "] ", + /*seed=*/-1, + /*lane=*/cut_pass); ++(*worker_count); #pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) \ @@ -3525,6 +3546,29 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut lp_status_t root_status = lp_status_t::UNSET; solving_root_relaxation_ = true; + // Started here so the lanes run through the root LP and every cut pass. No relaxation exists + // yet, so they seed from the anchor. + root_heuristics_t root_heuristics(settings_.num_threads - 1); + const i_t n_root_fj_lanes = + std::clamp(settings_.num_threads / 4, 0, CUOPT_MIP_ROOT_CPUFJ_MAX_LANES); + const f_t root_fj_time_limit = settings_.time_limit - toc(exploration_stats_.start_time); + if (!settings_.deterministic && n_root_fj_lanes > 0 && root_fj_time_limit > 0) { + root_heuristics.start_persistent_lanes( + original_lp_, + var_types_, + original_problem_.num_cols, + {}, + settings_, + n_root_fj_lanes, + root_fj_time_limit, + (int64_t)settings_.random_seed, + [this](f_t obj, const std::vector& assignment, double work_units) { + cuopt_assert(assignment.size() == (size_t)original_problem_.num_cols, + "root CPU FJ lanes must report a slack-free assignment"); + set_solution_from_cpu_fj(obj, assignment, work_units); + }); + } + f_t root_relax_start_time = tic(); if (!enable_concurrent_lp_root_solve()) { @@ -3682,8 +3726,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut compute_user_objective(original_lp_, root_relax_objective); } - root_heuristics_t root_heuristics(settings_.num_threads - 1); - f_t cut_generation_start_time = tic(); i_t cut_pool_size = 0; for (i_t cut_pass = 0; cut_pass < settings_.max_cut_passes; cut_pass++) { diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 5e16a8c4b3..c375b16441 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -2694,10 +2694,72 @@ static void finalize_fj_cpu_host_initialization_from_template( raft::device_span(fj_cpu.h_objective_vars.data(), fj_cpu.h_objective_vars.size()); } +// Slacks at and above n_structural fold into their row's bounds: a*x + alpha*s = rhs with +// s in [lo, hi] becomes rhs - max(alpha*lo, alpha*hi) <= a*x <= rhs - min(alpha*lo, alpha*hi). +template +static void eliminate_slacks(const lp_problem_t& problem, + i_t n_structural, + csr_matrix_t& csr_A, + std::vector& row_lower, + std::vector& row_upper) +{ + cuopt_assert(csr_A.m == problem.num_rows, "row count mismatch"); + cuopt_assert(csr_A.n == problem.num_cols, "column count mismatch"); + cuopt_assert(n_structural > 0, "no structural columns"); + cuopt_assert(n_structural < problem.num_cols, "no slacks to eliminate"); + cuopt_assert(problem.num_cols - n_structural <= problem.num_rows, "more slacks than rows"); + + row_lower = problem.rhs; + row_upper = problem.rhs; + + std::vector row_has_slack(problem.num_rows, 0); + for (i_t j = n_structural; j < problem.num_cols; ++j) { + cuopt_assert(problem.A.col_length(j) == 1, "slack column is not a singleton"); + + const i_t entry = problem.A.col_start[j]; + const i_t row = problem.A.i[entry]; + const f_t alpha = problem.A.x[entry]; + cuopt_assert(std::abs(alpha) == f_t{1}, "slack coefficient is not +/-1"); + cuopt_assert(!row_has_slack[row], "row has more than one slack"); + row_has_slack[row] = 1; + + const f_t scaled_lower = alpha * problem.lower[j]; + const f_t scaled_upper = alpha * problem.upper[j]; + row_lower[row] = problem.rhs[row] - std::max(scaled_lower, scaled_upper); + row_upper[row] = problem.rhs[row] - std::min(scaled_lower, scaled_upper); + cuopt_assert(std::isfinite(row_lower[row]) || std::isfinite(row_upper[row]), + "eliminated row is free on both sides"); + cuopt_assert(row_lower[row] <= row_upper[row], "eliminated row has crossed bounds"); + } + + i_t out = 0; + for (i_t row = 0; row < csr_A.m; ++row) { + const i_t row_start = csr_A.row_start[row]; + const i_t row_end = csr_A.row_start[row + 1]; + csr_A.row_start[row] = out; + for (i_t p = row_start; p < row_end; ++p) { + if (csr_A.j[p] >= n_structural) { continue; } + csr_A.j[out] = csr_A.j[p]; + csr_A.x[out] = csr_A.x[p]; + ++out; + } + } + cuopt_assert( + out == csr_A.row_start[csr_A.m] - static_cast(problem.num_cols - n_structural), + "slack elimination removed the wrong number of entries"); + + csr_A.row_start[csr_A.m] = out; + csr_A.j.resize(out); + csr_A.x.resize(out); + csr_A.nz_max = out; + csr_A.n = n_structural; +} + template static std::unique_ptr> init_fj_cpu_from_host_lp( const lp_problem_t& problem, const std::vector& variable_types, + i_t n_structural, const std::vector& seed_assignment, const simplex_solver_settings_t& settings, std::atomic& preemption_flag, @@ -2715,16 +2777,27 @@ static std::unique_ptr> init_fj_cpu_from_host_lp( tolerances.absolute_mip_gap = settings.absolute_mip_gap_tol; tolerances.relative_mip_gap = settings.relative_mip_gap_tol; - const i_t n_variables = problem.num_cols; const i_t n_constraints = problem.num_rows; csr_matrix_t csr_A(problem.num_rows, problem.num_cols, problem.A.nnz()); problem.A.to_compressed_row(csr_A); - std::vector coefficients = csr_A.x; - std::vector variables = csr_A.j; - std::vector offsets = csr_A.row_start; - std::vector constraint_lower_bounds = problem.rhs; - std::vector constraint_upper_bounds = problem.rhs; + + std::vector constraint_lower_bounds; + std::vector constraint_upper_bounds; + i_t n_variables; + if (n_structural > 0 && n_structural < problem.num_cols) { + eliminate_slacks(problem, n_structural, csr_A, constraint_lower_bounds, constraint_upper_bounds); + n_variables = n_structural; + } else { + n_variables = problem.num_cols; + // Standard form: every row is an equality. + constraint_lower_bounds = problem.rhs; + constraint_upper_bounds = problem.rhs; + } + + std::vector coefficients = csr_A.x; + std::vector variables = csr_A.j; + std::vector offsets = csr_A.row_start; std::vector variable_bounds(n_variables); std::vector cpufj_variable_types(n_variables); std::vector is_binary_variable(n_variables, 0); @@ -2781,8 +2854,9 @@ static std::unique_ptr> init_fj_cpu_from_host_lp( fj_cpu->h_coefficients = std::move(coefficients); fj_cpu->h_offsets = std::move(offsets); fj_cpu->h_variables = std::move(variables); - fj_cpu->h_obj_coeffs = problem.objective; - fj_cpu->h_var_bounds = std::move(variable_bounds); + fj_cpu->h_obj_coeffs = + std::vector(problem.objective.begin(), problem.objective.begin() + n_variables); + fj_cpu->h_var_bounds = std::move(variable_bounds); fj_cpu->h_cstr_lb = std::move(constraint_lower_bounds); fj_cpu->h_cstr_ub = std::move(constraint_upper_bounds); fj_cpu->h_var_types = std::move(cpufj_variable_types); @@ -3784,23 +3858,33 @@ void fj_cpu_worker_t::fj_cpu_deleter_t::operator()(fj_cpu_climber_t +std::shared_ptr> make_fj_cpu_shared_incumbent() +{ + return std::make_shared>(); +} + template void fj_cpu_worker_t::create_worker( const lp_problem_t& problem, const std::vector& variable_types, + i_t n_structural, const std::vector& seed_assignment, const simplex_solver_settings_t& settings, std::string log_prefix, - int64_t seed) + int64_t seed, + int lane) { auto new_climber = init_fj_cpu_from_host_lp( - problem, variable_types, seed_assignment, settings, preemption_flag, seed); + problem, variable_types, n_structural, seed_assignment, settings, preemption_flag, seed); fj_cpu.reset(new_climber.release()); fj_cpu->log_prefix = std::move(log_prefix); fj_cpu->improvement_callback = improvement_callback; + fj_cpu->shared_incumbent = shared_incumbent; fj_cpu->halted = false; preemption_flag = false; is_initialized = true; + if (lane >= 0) { apply_lane_diversification(*fj_cpu, lane, fj_cpu->settings.seed); } } template @@ -3847,6 +3931,8 @@ void fj_cpu_worker_t::send_stop_signal() #if MIP_INSTANTIATE_FLOAT template class fj_t; template struct fj_cpu_worker_t; +template std::shared_ptr> +make_fj_cpu_shared_incumbent(); template void cpufj_solve(fj_cpu_climber_t* fj_cpu, float in_time_limit, double work_unit_limit); @@ -3876,6 +3962,8 @@ template void finalize_fj_cpu_host_initialization( #if MIP_INSTANTIATE_DOUBLE template class fj_t; template struct fj_cpu_worker_t; +template std::shared_ptr> +make_fj_cpu_shared_incumbent(); template void cpufj_solve(fj_cpu_climber_t* fj_cpu, double in_time_limit, double work_unit_limit); diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh index bb2c69f81c..b30081059b 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh @@ -24,6 +24,13 @@ namespace cuopt::mathematical_optimization::mip { template struct fj_cpu_climber_t; +template +struct fj_cpu_shared_incumbent_t; + +// Defined in fj_cpu.cu, where the type is complete. +template +std::shared_ptr> make_fj_cpu_shared_incumbent(); + template struct fj_cpu_worker_t { // Custom deleter to avoid pulling the entire fj_cpu_climber_t class here. @@ -35,19 +42,26 @@ struct fj_cpu_worker_t { std::atomic preemption_flag{false}; std::unique_ptr, fj_cpu_deleter_t> fj_cpu; std::function&, double)> improvement_callback; + // Set before create_worker to join a portfolio; left null when the climber runs alone. + std::shared_ptr> shared_incumbent; ~fj_cpu_worker_t() { stop(); } + // `n_structural` is where `problem`'s slack block starts; those columns fold into two-sided row + // bounds, so the climber and the assignment it reports span only the ones below. -1 keeps them. // `seed` selects the FJ RNG seed: pass a non-negative value for a deterministic seed, // or -1 to draw from the global cuopt::seed_generator (the historical behavior). // In deterministic mode the caller MUST pass an explicit seed, otherwise the underlying // seed_generator::get_seed() racing with concurrent callers breaks reproducibility. + // `lane` >= 0 applies that lane's persona from the portfolio diversification ladder. void create_worker(const simplex::lp_problem_t& problem, const std::vector& variable_types, + i_t n_structural, const std::vector& seed_assignment, const simplex::simplex_solver_settings_t& settings, std::string log_prefix, - int64_t seed = -1); + int64_t seed = -1, + int lane = -1); // Run the worker asynchronously (i.e., launch an openmp task and then continue the // execution). Call `stop()` for stopping the worker diff --git a/cpp/src/mip_heuristics/mip_constants.hpp b/cpp/src/mip_heuristics/mip_constants.hpp index d09fa710c9..ab2451f817 100644 --- a/cpp/src/mip_heuristics/mip_constants.hpp +++ b/cpp/src/mip_heuristics/mip_constants.hpp @@ -25,6 +25,10 @@ * the problem and occupies an OMP task for the whole of presolve. */ #define CUOPT_MIP_EARLY_CPUFJ_MAX_CLIMBERS 8 +/* @brief Upper bound on the persistent root CPUFJ lane set. Every lane holds its own host copy of + * the root LP and occupies an OMP task for the whole of the cut loop. */ +#define CUOPT_MIP_ROOT_CPUFJ_MAX_LANES 4 + // MIP-only gate: skip the concurrent barrier when fewer threads are available than this // (1 PDLP + 1 dual simplex + 1 barrier). Stand-alone LP always runs all three. #define CUOPT_CONCURRENT_LP_BARRIER_REQUIRED_THREAD_COUNT 3 diff --git a/cpp/src/mip_heuristics/root_heuristics.hpp b/cpp/src/mip_heuristics/root_heuristics.hpp index 1f29b25eef..17a7a73e51 100644 --- a/cpp/src/mip_heuristics/root_heuristics.hpp +++ b/cpp/src/mip_heuristics/root_heuristics.hpp @@ -9,8 +9,15 @@ #include #include +#include #include "feasibility_jump/fj_cpu_worker.cuh" +#include +#include +#include +#include +#include + namespace cuopt::mathematical_optimization::mip { template @@ -91,19 +98,63 @@ struct root_heuristics_t { std::shared_ptr> worker_count_; i_t max_workers_; + // CPU FJ lanes that outlive a single cut pass. + std::vector>> persistent_lanes_; + // Shared by every CPU FJ lane of the root phase, persistent and per-cut-pass alike. + std::shared_ptr> shared_incumbent_; + root_heuristics_t(i_t max_workers) - : worker_count_(std::make_shared>(0)), max_workers_(max_workers) + : worker_count_(std::make_shared>(0)), + max_workers_(max_workers), + shared_incumbent_(make_fj_cpu_shared_incumbent()) { } ~root_heuristics_t() { stop_and_sync(); } + // Must be called from the same task region as stop_and_sync: run_async's task dependence is + // matched only by a taskwait in the encountering region. + void start_persistent_lanes(const simplex::lp_problem_t& lp, + const std::vector& var_types, + i_t n_structural, + const std::vector& seed_assignment, + const simplex::simplex_solver_settings_t& settings, + i_t n_lanes, + f_t time_limit, + int64_t base_seed, + std::function&, double)> callback) + { + persistent_lanes_.reserve(n_lanes); + for (i_t k = 0; k < n_lanes; ++k) { + auto lane = std::make_unique>(); + lane->improvement_callback = callback; + lane->shared_incumbent = shared_incumbent_; + lane->create_worker(lp, + var_types, + n_structural, + seed_assignment, + settings, + "[Root FJ lane " + std::to_string(k) + "] ", + base_seed + k, + k); + lane->run_async(time_limit); + persistent_lanes_.push_back(std::move(lane)); + } + } + void stop_and_sync() { + for (auto& lane : persistent_lanes_) { + lane->send_stop_signal(); + } for (auto& heuristic : cut_passes_heuristics_) { heuristic->send_stop_signal(); } + for (auto& lane : persistent_lanes_) { + lane->stop(); + } + persistent_lanes_.clear(); for (auto& heuristic : cut_passes_heuristics_) { heuristic->stop_and_sync(); } @@ -127,8 +178,12 @@ struct root_heuristics_t { cut_passes_heuristics_.erase(cut_passes_heuristics_.begin()); } - return cut_passes_heuristics_.emplace_back(std::make_shared>( - Arow, var_types, root_solution, root_edge_norm)); + auto& heuristic = cut_passes_heuristics_.emplace_back( + std::make_shared>( + Arow, var_types, root_solution, root_edge_norm)); + // Read by create_worker, so it has to be in place before the caller builds the climber. + heuristic->fj_cpu_worker_.shared_incumbent = shared_incumbent_; + return heuristic; } }; From 7eda27d71fae6ab01724a392573ee4430e487e51 Mon Sep 17 00:00:00 2001 From: yboucher Date: Tue, 25 Aug 2026 05:31:55 -0700 Subject: [PATCH 78/78] many lanes during early heuristics --- .../mip_heuristics/diversity/diversity_manager.cu | 11 +++++++++++ .../mip_heuristics/feasibility_jump/early_cpufj.cu | 4 ++-- .../mip_heuristics/feasibility_jump/early_cpufj.cuh | 6 +++++- cpp/src/mip_heuristics/mip_constants.hpp | 6 +++--- cpp/src/mip_heuristics/solve.cu | 12 ++++++++---- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/cpp/src/mip_heuristics/diversity/diversity_manager.cu b/cpp/src/mip_heuristics/diversity/diversity_manager.cu index ec82c4b423..e69022c42d 100644 --- a/cpp/src/mip_heuristics/diversity/diversity_manager.cu +++ b/cpp/src/mip_heuristics/diversity/diversity_manager.cu @@ -8,6 +8,7 @@ #include "cuda_profiler_api.h" #include "diversity_manager.cuh" +#include #include #include @@ -22,6 +23,9 @@ #include #include +#include + +#include #include #include #include @@ -314,6 +318,13 @@ bool diversity_manager_t::run_presolve(f_t time_limit, timer_t global_ if (run_probing_cache && !global_timer.check_time_limit() && !presolve_timer.check_time_limit()) { log_presolve_budget("PROBING", probing_features, probing_budget); + // The early CPUFJ lanes hold their threads for the whole of presolve, and probing's default + // task count assumes the whole team. Its pools are sized per task, so this bounds host memory + // as well as concurrency. + const i_t held_by_cpufj = + context.early_cpufj_ptr != nullptr ? (i_t)context.early_cpufj_ptr->lane_count() : 0; + ls.constraint_prop.bounds_update.settings.num_tasks = + std::max(1, omp_get_num_threads() - 1 - held_by_cpufj); f_t time_for_probing_cache = std::min(time_limit, (f_t)global_timer.remaining_time()); timer_t probing_timer{time_for_probing_cache}; [[maybe_unused]] const auto probing_t0 = std::chrono::steady_clock::now(); diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu index 8b1444d8a1..5f9a68ac99 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu @@ -33,7 +33,7 @@ early_cpufj_t::~early_cpufj_t() } template -void early_cpufj_t::start() +void early_cpufj_t::start(int n_lanes) { // 1: presolve, 1: early GPU FJ, 1: early CPU FJ if (!climbers_.empty() || omp_get_num_threads() < CUOPT_MIP_EARLY_CPUFJ_REQUIRED_THREAD_COUNT) { @@ -45,7 +45,7 @@ void early_cpufj_t::start() // Tasks are not preempted, so a lane posted beyond the team size would sit in the queue for the // whole of presolve without running an iteration. - const int n_lanes = std::min(CUOPT_MIP_EARLY_CPUFJ_MAX_CLIMBERS, omp_get_num_threads()); + n_lanes = std::clamp(n_lanes, 1, omp_get_num_threads()); const int64_t base_seed = cuopt::seed_generator::get_seed(); climbers_.resize(n_lanes); diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh index ede2ffe168..3bae5ed63b 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh @@ -28,9 +28,13 @@ class early_cpufj_t : public early_heuristic_t static constexpr const char* name() { return "CPUFJ"; } - void start(); + // Lanes are OMP tasks that never yield, so n_lanes threads are unavailable to anything else + // until stop(). Callers sharing the team with other work size it accordingly. + void start(int n_lanes); void stop(); + int lane_count() const { return (int)climbers_.size(); } + private: friend class early_heuristic_t>; diff --git a/cpp/src/mip_heuristics/mip_constants.hpp b/cpp/src/mip_heuristics/mip_constants.hpp index ab2451f817..e5c85a65fa 100644 --- a/cpp/src/mip_heuristics/mip_constants.hpp +++ b/cpp/src/mip_heuristics/mip_constants.hpp @@ -21,9 +21,9 @@ #define CUOPT_MIP_BATCH_PDLP_REQUIRED_THREAD_COUNT 3 #define CUOPT_MIP_CLIQUE_CUTS_REQUIRED_THREAD_COUNT 3 -/* @brief Upper bound on the early CPUFJ climber portfolio. Every lane holds its own host copy of - * the problem and occupies an OMP task for the whole of presolve. */ -#define CUOPT_MIP_EARLY_CPUFJ_MAX_CLIMBERS 8 +/* @brief Threads the early CPUFJ portfolio leaves to the rest of the team. Every lane holds its + * own host copy of the problem and occupies an OMP task for the whole of presolve. */ +#define CUOPT_MIP_EARLY_CPUFJ_RESERVED_THREADS 4 /* @brief Upper bound on the persistent root CPUFJ lane set. Every lane holds its own host copy of * the root LP and occupies an OMP task for the whole of the cut loop. */ diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index 5371a87026..c42bac4365 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -296,9 +296,11 @@ mip_solution_t run_mip_solver( if (std::isfinite(initial_upper_bound)) { early_cpufj->set_best_objective(problem.get_solver_obj_from_user_obj(initial_upper_bound)); } - early_cpufj->start(); + early_cpufj->start(omp_get_num_threads() - CUOPT_MIP_EARLY_CPUFJ_RESERVED_THREADS); solver.context.early_cpufj_ptr = early_cpufj.get(); - CUOPT_LOG_DEBUG("Started early CPUFJ on papilo-presolved problem during cuOpt presolve"); + CUOPT_LOG_DEBUG( + "Started early CPUFJ on papilo-presolved problem during cuOpt presolve with %d lanes", + early_cpufj->lane_count()); } auto presolved_sol = solver.run_solver(); @@ -512,8 +514,10 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p // Start early CPUFJ on original problem (will restart on presolved problem after Papilo) early_cpufj = std::make_unique>( op_problem, settings.get_tolerances(), early_fj_callback); - early_cpufj->start(); - CUOPT_LOG_DEBUG("Started early CPUFJ on original problem"); + // Papilo runs on its own threads, so the team is otherwise idle here. + early_cpufj->start(omp_get_num_threads() - CUOPT_MIP_EARLY_CPUFJ_RESERVED_THREADS); + CUOPT_LOG_DEBUG("Started early CPUFJ on original problem with %d lanes", + early_cpufj->lane_count()); } auto early_cpufj_guard = cuopt::scope_guard([&]() {