Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4fe3f94
Initial symmetry detection using dejavu
chris-maes Apr 15, 2026
ada114b
First stab at orbital fixing. Using dejavu for graph operations. Fix …
chris-maes Apr 16, 2026
6425c25
fixed incorrect computation of the lower bound when running with a si…
nguidotti Apr 16, 2026
f447d4c
Revert changes for single-thread MIP hang
chris-maes Apr 16, 2026
89ce17f
Merge remote-tracking branch 'cuopt-nvidia/pull-request/1111' into sy…
chris-maes Apr 16, 2026
fcd28d7
Add mip-symmetry setting. Give each worker a unique orbital_fixing_t …
chris-maes Apr 16, 2026
96010da
Only do strong branching on representative variables in an orbit. Ext…
chris-maes Apr 17, 2026
7ea524f
Remove dejavu use for everything but graph automorphism. Dont compute…
chris-maes Apr 18, 2026
426582a
Use the fact that we are performing a plunge to avoid recomputing the…
chris-maes Apr 20, 2026
07d421b
Remove generators that don't perserve bounds after cuts and root boun…
chris-maes Apr 21, 2026
24acd6f
Fix conflicts coming from root fixings. Fix non-monotonic bound by st…
chris-maes Apr 21, 2026
659a7c4
Log orbital fixings
chris-maes Apr 21, 2026
7bd9569
Skip orbits with conflicting fixings. Fix bug where orbital fixing wa…
chris-maes Apr 22, 2026
2aba39f
More fixes
chris-maes Apr 22, 2026
d4658ed
Add lexical reduction
chris-maes Apr 24, 2026
6cdc089
Fix malformed comment
chris-maes Apr 24, 2026
1dfa784
Add clang-format off/on to prevent comment from being mangled again
chris-maes Apr 24, 2026
9c41f40
Repeat symmetry detection if trivial presolve changes the problems. U…
chris-maes Apr 24, 2026
18cf2df
Merge remote-tracking branch 'cuopt-nvidia/main' into symmetry_detection
chris-maes Apr 27, 2026
c607017
Detect symmetry after presolve
chris-maes Apr 27, 2026
98cef5a
Move detect symmetry back until all of cuOpt presolve is complete
chris-maes Apr 27, 2026
a422f86
generators_t -> group_generators_t
chris-maes May 21, 2026
e054968
Style fixes
chris-maes May 21, 2026
e787ec1
Merge remote-tracking branch 'cuopt-nvidia/release/26.06' into symmet…
chris-maes May 21, 2026
a7126e4
factor out symmetry reductions from solve_node_lp
chris-maes May 21, 2026
c890f2f
Style fixes
chris-maes May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,20 @@ FetchContent_MakeAvailable(pslp)
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(
dejavu
GIT_REPOSITORY "https://github.com/markusa4/dejavu.git"
GIT_TAG "v2.1"
GIT_PROGRESS TRUE
EXCLUDE_FROM_ALL
SYSTEM
SOURCE_SUBDIR _nonexistent
)
FetchContent_MakeAvailable(dejavu)
message(STATUS "dejavu (graph automorphism): ${dejavu_SOURCE_DIR}")

include(${rapids-cmake-dir}/cpm/rapids_logger.cmake)
# generate logging macros
rapids_cpm_rapids_logger(BUILD_EXPORT_SET cuopt-exports INSTALL_EXPORT_SET cuopt-exports)
Expand Down Expand Up @@ -469,6 +483,7 @@ target_include_directories(cuopt PRIVATE

target_include_directories(cuopt SYSTEM PRIVATE
"${pslp_SOURCE_DIR}/include"
"${dejavu_SOURCE_DIR}"
)

target_include_directories(cuopt
Expand Down
166 changes: 163 additions & 3 deletions cpp/src/branch_and_bound/branch_and_bound.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <branch_and_bound/branch_and_bound.hpp>
#include <branch_and_bound/mip_node.hpp>
#include <branch_and_bound/pseudo_costs.hpp>
#include <branch_and_bound/symmetry.hpp>

#include <cuts/cuts.hpp>
#include <mip_heuristics/presolve/conflict_graph/clique_table.cuh>
Expand Down Expand Up @@ -248,11 +249,13 @@ branch_and_bound_t<i_t, f_t>::branch_and_bound_t(
const simplex_solver_settings_t<i_t, f_t>& solver_settings,
f_t start_time,
const probing_implied_bound_t<i_t, f_t>& probing_implied_bound,
std::shared_ptr<detail::clique_table_t<i_t, f_t>> clique_table)
std::shared_ptr<detail::clique_table_t<i_t, f_t>> clique_table,
mip_symmetry_t<i_t, f_t>* symmetry)
: original_problem_(user_problem),
settings_(solver_settings),
probing_implied_bound_(probing_implied_bound),
clique_table_(std::move(clique_table)),
symmetry_(symmetry),
original_lp_(user_problem.handle_ptr, 1, 1, 1),
Arow_(1, 1, 0),
incumbent_(1),
Expand Down Expand Up @@ -1388,6 +1391,163 @@ dual::status_t branch_and_bound_t<i_t, f_t>::solve_node_lp(
worker->leaf_edge_norms = edge_norms_;

if (feasible) {


// Perform orbital fixing
if (symmetry_ != nullptr) {
// First get the set of variables that have been branched down and branched up on
std::vector<i_t> branched_zero;
std::vector<i_t> branched_one;
branched_zero.reserve(node_ptr->depth);
branched_one.reserve(node_ptr->depth);
mip_node_t<i_t, f_t>* node = node_ptr;
while (node != nullptr && node->branch_var >= 0) {
if (node->branch_var_upper == 0.0) {
branched_zero.push_back(node->branch_var);
symmetry_->marked_b0[node->branch_var] = 1;
} else if (node->branch_var_lower == 1.0) {
branched_one.push_back(node->branch_var);
symmetry_->marked_b1[node->branch_var] = 1;
} else {
assert(false); // Unexpected non-binary variable. Only binaries supported in symmetry handling.
}
node = node->parent;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

{
for (i_t j = 0; j < symmetry_->num_original_vars; j++) {
if (var_types_[j] == variable_type_t::CONTINUOUS) continue;
if (symmetry_->marked_b1[j] == 0 && worker->leaf_problem.lower[j] == 1.0) {
symmetry_->f1.push_back(j);
symmetry_->marked_f1[j] = 1;
}
if (symmetry_->marked_b0[j] == 0 && worker->leaf_problem.upper[j] == 0.0) {
symmetry_->f0.push_back(j);
symmetry_->marked_f0[j] = 1;
}
}

// Compute Stab(G, B1) and its orbits
std::vector<i_t> new_base;
new_base.reserve(symmetry_->num_original_vars);
for (i_t j: branched_one) {
new_base.push_back(j);
symmetry_->marked_variables[j] = 1;
}
for (i_t j = 0; j < symmetry_->num_original_vars; j++) {
if (symmetry_->marked_variables[j] == 0) {
new_base.push_back(j);
}
}
for (i_t j: branched_one) {
symmetry_->marked_variables[j] = 0;
}

symmetry_->schreier->set_base(new_base);

dejavu::groups::orbit orb;
orb.initialize(symmetry_->domain_size);
symmetry_->schreier->get_stabilizer_orbit(static_cast<int>(branched_one.size()), orb);

for (i_t v : branched_one) {
symmetry_->orbit_has_b1[orb.find_orbit(v)] = 1;
}

for (i_t v : branched_zero) {
symmetry_->orbit_has_b0[orb.find_orbit(v)] = 1;
}

for (i_t v: symmetry_->continuous_variables) {
symmetry_->orbit_has_continuous[orb.find_orbit(v)] = 1;
}

for (i_t v: symmetry_->f0) {
symmetry_->orbit_has_f0[orb.find_orbit(v)] = 1;
}

for (i_t v: symmetry_->f1) {
symmetry_->orbit_has_f1[orb.find_orbit(v)] = 1;
}

std::vector<i_t> fix_zero; // The set L0 of variables that can be fixed to 0
std::vector<i_t> fix_one; // The set L1 of variables that can be fixed to 1

for (i_t j = 0; j < symmetry_->num_original_vars; j++) {
i_t o = orb.find_orbit(j);
if (orb.orbit_size(o) < 2) continue;

if (symmetry_->orbit_has_b1[o] == 1 || symmetry_->orbit_has_continuous[o] == 1) {
// The orbit contains variables in B1 or continuous variables
// So we can't fix any variables in this orbit to 0
continue;
}

if (symmetry_->orbit_has_b0[o] == 1 || symmetry_->orbit_has_f0[o] == 1) {
// The orbit of this variable contains variables in B0 or F0
// So we can fix this variable to zero (provided its not already in B0 or F0)
if (symmetry_->marked_b0[j] == 0 && symmetry_->marked_f0[j] == 0) {
fix_zero.push_back(j);
}
}

if (symmetry_->orbit_has_f1[o] == 1) {
// The orbit of this variable contains variables in F1
// So we can fix this variable to one (provided its not already in F1)
if (symmetry_->marked_f1[j] == 0) {
fix_one.push_back(j);
}
}
}

// Restore the work arrays
for (i_t v: branched_one) {
symmetry_->orbit_has_b1[orb.find_orbit(v)] = 0;
symmetry_->marked_b1[v] = 0;
}

for (i_t v: branched_zero) {
symmetry_->orbit_has_b0[orb.find_orbit(v)] = 0;
symmetry_->marked_b0[v] = 0;
}

for (i_t v: symmetry_->continuous_variables) {
symmetry_->orbit_has_continuous[orb.find_orbit(v)] = 0;
}

for (i_t v: symmetry_->f0) {
symmetry_->orbit_has_f0[orb.find_orbit(v)] = 0;
symmetry_->marked_f0[v] = 0;
}

for (i_t v: symmetry_->f1) {
symmetry_->orbit_has_f1[orb.find_orbit(v)] = 0;
symmetry_->marked_f1[v] = 0;
}

symmetry_->f0.clear();
symmetry_->f1.clear();

settings_.log.printf(
"Orbital fixing at node %d: fixing %d variables to 0 and %d variables to 1\n",
node_ptr->node_id,
fix_zero.size(),
fix_one.size());
// Finally fix the variables in L0 and L1
for (i_t v: fix_zero) {
settings_.log.printf("Orbital fixing at node %d: fixing variable %d to 0\n", node_ptr->node_id, v);
worker->leaf_problem.lower[v] = 0.0;
worker->leaf_problem.upper[v] = 0.0;
}
for (i_t v: fix_one) {
settings_.log.printf("Orbital fixing at node %d: fixing variable %d to 1\n", node_ptr->node_id, v);
worker->leaf_problem.lower[v] = 1.0;
worker->leaf_problem.upper[v] = 1.0;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated



i_t node_iter = 0;
f_t lp_start_time = tic();

Expand Down Expand Up @@ -1438,7 +1598,7 @@ void branch_and_bound_t<i_t, f_t>::plunge_with(branch_and_bound_worker_t<i_t, f_
worker->recompute_basis = true;
worker->recompute_bounds = true;

f_t lower_bound = get_lower_bound();
f_t lower_bound = std::min(get_lower_bound(), worker->start_node->lower_bound);
f_t upper_bound = upper_bound_;
f_t rel_gap = user_relative_gap(original_lp_, upper_bound, lower_bound);
f_t abs_gap = compute_user_abs_gap(original_lp_, upper_bound, lower_bound);
Expand Down Expand Up @@ -1582,7 +1742,7 @@ void branch_and_bound_t<i_t, f_t>::dive_with(branch_and_bound_worker_t<i_t, f_t>
dive_stats.nodes_explored = 0;
dive_stats.nodes_unexplored = 1;

f_t lower_bound = get_lower_bound();
f_t lower_bound = std::min(get_lower_bound(), worker->start_node->lower_bound);
f_t upper_bound = upper_bound_;
f_t rel_gap = user_relative_gap(original_lp_, upper_bound, lower_bound);
f_t abs_gap = compute_user_abs_gap(original_lp_, upper_bound, lower_bound);
Expand Down
7 changes: 6 additions & 1 deletion cpp/src/branch_and_bound/branch_and_bound.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ struct clique_table_t;

namespace cuopt::linear_programming::dual_simplex {

template <typename i_t, typename f_t>
struct mip_symmetry_t;

enum class mip_status_t {
OPTIMAL = 0, // The optimal integer solution was found
UNBOUNDED = 1, // The problem is unbounded
Expand Down Expand Up @@ -80,7 +83,8 @@ class branch_and_bound_t {
const simplex_solver_settings_t<i_t, f_t>& solver_settings,
f_t start_time,
const probing_implied_bound_t<i_t, f_t>& probing_implied_bound,
std::shared_ptr<detail::clique_table_t<i_t, f_t>> clique_table = nullptr);
std::shared_ptr<detail::clique_table_t<i_t, f_t>> clique_table = nullptr,
mip_symmetry_t<i_t, f_t>* symmetry = nullptr);

// Set an initial guess based on the user_problem. This should be called before solve.
void set_initial_guess(const std::vector<f_t>& user_guess) { guess_ = user_guess; }
Expand Down Expand Up @@ -162,6 +166,7 @@ class branch_and_bound_t {
const simplex_solver_settings_t<i_t, f_t> settings_;
const probing_implied_bound_t<i_t, f_t>& probing_implied_bound_;
std::shared_ptr<detail::clique_table_t<i_t, f_t>> clique_table_;
mip_symmetry_t<i_t, f_t>* symmetry_;
std::future<std::shared_ptr<detail::clique_table_t<i_t, f_t>>> clique_table_future_;
std::atomic<bool> signal_extend_cliques_{false};

Expand Down
Loading