From 3100497515cc2669ae31fd9b44c8efdb4d575ad1 Mon Sep 17 00:00:00 2001 From: akif Date: Mon, 20 Jul 2026 10:33:11 +0200 Subject: [PATCH 1/3] fix the size issue on many large clique problems --- .../presolve/conflict_graph/clique_table.cuh | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cuh b/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cuh index 3ddc1524de..fe6db31c26 100644 --- a/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cuh +++ b/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cuh @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -22,7 +23,12 @@ namespace cuopt::mathematical_optimization::mip { struct clique_config_t { - int min_clique_size = 512; + // Cliques with size <= min_clique_size are demoted by remove_small_cliques into + // explicit pairwise edges (O(size^2) each); larger cliques stay structured in + // `first`. Both representations encode the same conflict edges, so this only + // trades memory for adjacency-query speed. Keep the threshold small so mixed-row + // sub-cliques (which are frequently mid-sized) do not blow up the edge list. + int min_clique_size = 64; int max_clique_size_for_extension = 128; // extend_cliques work budget; one unit ≈ one hash/scan op in extend_clique. // Soft floor before honoring cut-gen signal; hard ceiling. @@ -59,20 +65,21 @@ struct addtl_clique_t { // pairs and call `finalize_from_unsorted_pairs`. template struct csr_var_map_t { - std::vector offsets; // size: n_vertices + 1; offsets[v] is the start in `indices` - std::vector indices; // sorted within each [offsets[v], offsets[v+1]) slice + std::vector offsets; // size: n_vertices + 1; offsets[v] is the start in `indices` + std::vector indices; // sorted within each [offsets[v], offsets[v+1]) slice void clear_and_resize(i_t n_vertices) { - offsets.assign(n_vertices + 1, 0); + offsets.assign(static_cast(n_vertices) + 1, 0); indices.clear(); } i_t n_keys() const { return offsets.empty() ? 0 : static_cast(offsets.size() - 1); } - i_t slice_size(i_t v) const { return offsets[v + 1] - offsets[v]; } + int64_t slice_size(i_t v) const { return offsets[v + 1] - offsets[v]; } // Range-for friendly view of the sorted slice for vertex v. std::span slice(i_t v) const { - return {indices.data() + offsets[v], (size_t)(offsets[v + 1] - offsets[v])}; + return {indices.data() + static_cast(offsets[v]), + static_cast(offsets[v + 1] - offsets[v])}; } // O(1) summary used by cut/extension cost-budget heuristics. double avg_slice_size() const @@ -90,36 +97,38 @@ struct csr_var_map_t { // and deduplicated. Caller must keep p.first in [0, n_vertices). void finalize_from_unsorted_pairs(i_t n_vertices, std::vector>& pairs) { - offsets.assign(n_vertices + 1, 0); + offsets.assign(static_cast(n_vertices) + 1, 0); for (const auto& p : pairs) { - offsets[p.first + 1]++; + ++offsets[p.first + 1]; } for (i_t v = 1; v <= n_vertices; ++v) { offsets[v] += offsets[v - 1]; } indices.assign(static_cast(offsets.back()), i_t{0}); - std::vector head(n_vertices, 0); + std::vector head(static_cast(n_vertices), 0); for (const auto& p : pairs) { - indices[offsets[p.first] + head[p.first]++] = p.second; + const size_t pos = + static_cast(offsets[p.first]) + static_cast(head[p.first]++); + indices[pos] = p.second; } for (i_t v = 0; v < n_vertices; ++v) { - auto* b = indices.data() + offsets[v]; - auto* e = indices.data() + offsets[v] + head[v]; + auto* b = indices.data() + static_cast(offsets[v]); + auto* e = indices.data() + static_cast(offsets[v] + head[v]); std::sort(b, e); auto* new_end = std::unique(b, e); - head[v] = static_cast(new_end - b); + head[v] = static_cast(new_end - b); } // Compact away dedupe holes. - std::vector new_offsets(n_vertices + 1, 0); + std::vector new_offsets(static_cast(n_vertices) + 1, 0); for (i_t v = 0; v < n_vertices; ++v) { new_offsets[v + 1] = new_offsets[v] + head[v]; } if (new_offsets.back() != offsets.back()) { std::vector new_indices(static_cast(new_offsets.back())); for (i_t v = 0; v < n_vertices; ++v) { - std::copy(indices.data() + offsets[v], - indices.data() + offsets[v] + head[v], - new_indices.data() + new_offsets[v]); + std::copy(indices.data() + static_cast(offsets[v]), + indices.data() + static_cast(offsets[v] + head[v]), + new_indices.data() + static_cast(new_offsets[v])); } offsets = std::move(new_offsets); indices = std::move(new_indices); From 013f87c1ec37494843cf7fb8eefc54e79d7be8d1 Mon Sep 17 00:00:00 2001 From: akif Date: Mon, 20 Jul 2026 11:19:12 +0200 Subject: [PATCH 2/3] fix roundoff bug and add tolerance to clique row extraction --- .../presolve/conflict_graph/clique_table.cu | 5 ++-- cpp/tests/mip/cuts_test.cu | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cu b/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cu index bc3b04a089..dd2e958d73 100644 --- a/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cu +++ b/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cu @@ -30,14 +30,15 @@ void find_cliques_from_constraint(const knapsack_constraint_t& kc, { i_t size = kc.entries.size(); cuopt_assert(size > 1, "Constraint has not enough variables"); - if (kc.entries[size - 1].val + kc.entries[size - 2].val <= kc.rhs) { return; } + const f_t rhs_with_tolerance = kc.rhs + clique_table.tolerances.absolute_tolerance; + if (kc.entries[size - 1].val + kc.entries[size - 2].val <= rhs_with_tolerance) { return; } std::vector clique; i_t k = size - 1; // find the first clique, which is the largest // FIXME: do binary search // require k >= 1 so kc.entries[k-1] is always valid - while (k >= 1 && kc.entries[k].val + kc.entries[k - 1].val > kc.rhs) { + while (k >= 1 && kc.entries[k].val + kc.entries[k - 1].val > rhs_with_tolerance) { k--; } for (i_t idx = k; idx < size; idx++) { diff --git a/cpp/tests/mip/cuts_test.cu b/cpp/tests/mip/cuts_test.cu index 9816db55f2..a0ebad5eec 100644 --- a/cpp/tests/mip/cuts_test.cu +++ b/cpp/tests/mip/cuts_test.cu @@ -143,6 +143,23 @@ End )LP"); } +io::mps_data_model_t create_mixed_row_roundoff_non_conflict_problem() +{ + return cuopt::test::parse_inline_lp(R"LP( +Minimize + obj: 0 x0 + 0 x1 + 0 y0 + 0 y1 +Subject To + c1: -0.08 x0 - 0.08 x1 + 0.1 y0 + 0.2 y1 <= 0.3 +Bounds + 1 <= y0 <= 1 + 1 <= y1 <= 1 +Binaries + x0 + x1 +End +)LP"); +} + // x0 + x1 <= 1 but x1 has upper bound 0.9999999, so this row should not be // treated as a binary conflict row. io::mps_data_model_t create_near_binary_bound_conflict_problem() @@ -1161,6 +1178,16 @@ TEST(cuts, clique_phase5_extracts_binary_subclique_from_mixed_row) EXPECT_FALSE(clique_table.check_adjacency(0, 2)); } +TEST(cuts, clique_phase5_mixed_row_roundoff_does_not_create_conflict) +{ + const raft::handle_t handle{}; + auto problem = create_mixed_row_roundoff_non_conflict_problem(); + auto clique_table = build_clique_table_for_model(handle, problem); + const int num_vars = problem.get_n_variables(); + + EXPECT_FALSE(clique_table.check_adjacency(num_vars, num_vars + 1)); +} + TEST(cuts, clique_phase5_ignores_fractional_binary_bounds) { const raft::handle_t handle{}; From 13fd59729b5dd13f1b1a577ffacabfdd8995ee64 Mon Sep 17 00:00:00 2001 From: akif Date: Mon, 20 Jul 2026 11:42:44 +0200 Subject: [PATCH 3/3] add more tolerance checks --- .../presolve/conflict_graph/clique_table.cu | 12 ++++---- cpp/tests/mip/cuts_test.cu | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cu b/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cu index dd2e958d73..a8e6997572 100644 --- a/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cu +++ b/cpp/src/mip_heuristics/presolve/conflict_graph/clique_table.cu @@ -52,16 +52,18 @@ void find_cliques_from_constraint(const knapsack_constraint_t& kc, if (timer.check_time_limit()) { return; } f_t curr_val = kc.entries[k].val; i_t curr_col = kc.entries[k].col; - // do a binary search in the clique coefficients to find f, such that coeff_k + coeff_f > rhs - // this means that we get a subset of the original clique and extend it with a variable + // do a binary search in the clique coefficients to find f, such that + // coeff_k + coeff_f > rhs_with_tolerance f_t val_to_find = kc.rhs - curr_val + clique_table.tolerances.absolute_tolerance; - auto it = std::lower_bound( - kc.entries.begin() + original_clique_start_idx, kc.entries.end(), val_to_find); + auto it = std::upper_bound(kc.entries.begin() + original_clique_start_idx, + kc.entries.end(), + val_to_find, + [](f_t value, const auto& entry) { return value < entry.val; }); if (it != kc.entries.end()) { i_t position_on_knapsack_constraint = std::distance(kc.entries.begin(), it); i_t start_pos_on_clique = position_on_knapsack_constraint - original_clique_start_idx; cuopt_assert(start_pos_on_clique >= 1, "Start position on clique is negative"); - cuopt_assert(it->val + curr_val > kc.rhs, "RHS mismatch"); + cuopt_assert(it->val + curr_val > rhs_with_tolerance, "RHS mismatch"); #if DEBUG_KNAPSACK_CONSTRAINTS CUOPT_LOG_DEBUG("Found additional clique: %d, %d, %d", curr_col, diff --git a/cpp/tests/mip/cuts_test.cu b/cpp/tests/mip/cuts_test.cu index a0ebad5eec..b4fc3e8cc7 100644 --- a/cpp/tests/mip/cuts_test.cu +++ b/cpp/tests/mip/cuts_test.cu @@ -196,6 +196,22 @@ End )LP"); } +io::mps_data_model_t create_addtl_clique_tolerance_boundary_problem() +{ + return cuopt::test::parse_inline_lp(R"LP( +Minimize + obj: 0 x0 + 0 x1 + 0 x2 + 0 x3 +Subject To + c1: x0 + 2 x1 + 3.000001 x2 + 4 x3 <= 5 +Binaries + x0 + x1 + x2 + x3 +End +)LP"); +} + mip::clique_table_t build_clique_table_for_model_with_min_size( const raft::handle_t& handle, const io::mps_data_model_t& model, int min_clique_size) { @@ -1036,6 +1052,18 @@ TEST(cuts, clique_phase1_addtl_conflict_symmetry_and_reverse_lookup) EXPECT_TRUE(adj_of_3.count(1) > 0); } +TEST(cuts, clique_phase1_addtl_conflict_rejects_tolerance_boundary) +{ + const raft::handle_t handle{}; + auto problem = create_addtl_clique_tolerance_boundary_problem(); + auto clique_table = build_clique_table_for_model_with_min_size(handle, problem, 1); + + ASSERT_FALSE(clique_table.addtl_cliques.empty()); + EXPECT_TRUE(clique_table.check_adjacency(2, 3)); + EXPECT_TRUE(clique_table.check_adjacency(1, 3)); + EXPECT_FALSE(clique_table.check_adjacency(1, 2)); +} + TEST(cuts, clique_phase1_remove_small_cliques_preserves_addtl_conflicts) { const raft::handle_t handle{};