Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
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
17 changes: 16 additions & 1 deletion cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,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 @@ -471,7 +485,8 @@ target_include_directories(cuopt PRIVATE
)

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

target_include_directories(cuopt
Expand Down
1 change: 1 addition & 0 deletions cpp/include/cuopt/linear_programming/constants.h
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
#define CUOPT_MIP_HEURISTICS_ONLY "mip_heuristics_only"
#define CUOPT_MIP_SCALING "mip_scaling"
#define CUOPT_MIP_PRESOLVE "mip_presolve"
#define CUOPT_MIP_SYMMETRY "mip_symmetry"
#define CUOPT_MIP_RELIABILITY_BRANCHING "mip_reliability_branching"
#define CUOPT_MIP_CUT_PASSES "mip_cut_passes"
#define CUOPT_MIP_MIXED_INTEGER_ROUNDING_CUTS "mip_mixed_integer_rounding_cuts"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class mip_solver_settings_t {
bool heuristics_only = false;
i_t reliability_branching = -1;
i_t num_cpu_threads = -1; // -1 means use default number of threads in branch and bound
i_t symmetry = -1;
i_t max_cut_passes = 10; // number of cut passes to make
i_t mir_cuts = -1;
i_t mixed_integer_gomory_cuts = -1;
Expand Down
134 changes: 101 additions & 33 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 @@ -730,6 +733,20 @@ void branch_and_bound_t<i_t, f_t>::set_final_solution(mip_solution_t<i_t, f_t>&
settings_.log.printf("Explored %d nodes in %.2fs.\n",
exploration_stats_.nodes_explored,
toc(exploration_stats_.start_time));
if (exploration_stats_.orbital_fixing_nodes.load() > 0 ||
exploration_stats_.orbital_conflict_nodes.load() > 0) {
settings_.log.printf("Orbital fixing applied at %lld nodes, %lld total variable fixings, "
"%lld nodes with conflicting orbits\n",
(long long)exploration_stats_.orbital_fixing_nodes.load(),
(long long)exploration_stats_.orbital_fixings_applied.load(),
(long long)exploration_stats_.orbital_conflict_nodes.load());
}
if (exploration_stats_.lexical_reduction_nodes.load() > 0) {
settings_.log.printf("Lexical reduction applied at %lld nodes, %lld total variable fixings, %lld nodes pruned\n",
(long long)exploration_stats_.lexical_reduction_nodes.load(),
(long long)exploration_stats_.lexical_reduction_fixings_applied.load(),
(long long)exploration_stats_.lexical_reduction_pruned_nodes.load());
}
settings_.log.printf("Absolute Gap %e Objective %.16e %s Bound %.16e\n",
gap,
obj,
Expand Down Expand Up @@ -1387,42 +1404,79 @@ dual::status_t branch_and_bound_t<i_t, f_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_;
if (worker->recompute_bounds && worker->orbital_fixing &&
worker->search_strategy == BEST_FIRST) {
worker->orbital_fixing->reset(symmetry_, node_ptr);
}

if (feasible) {
i_t node_iter = 0;
f_t lp_start_time = tic();

lp_status = dual_phase2_with_advanced_basis(2,
0,
worker->recompute_basis,
lp_start_time,
worker->leaf_problem,
lp_settings,
leaf_vstatus,
worker->basis_factors,
worker->basic_list,
worker->nonbasic_list,
worker->leaf_solution,
node_iter,
worker->leaf_edge_norms);

if (lp_status == dual::status_t::NUMERICAL) {
log.debug("Numerical issue node %d. Resolving from scratch.\n", node_ptr->node_id);
lp_status_t second_status = solve_linear_program_with_advanced_basis(worker->leaf_problem,
lp_start_time,
lp_settings,
worker->leaf_solution,
worker->basis_factors,
worker->basic_list,
worker->nonbasic_list,
leaf_vstatus,
worker->leaf_edge_norms);
// Perform orbital fixing
auto* of = worker->orbital_fixing.get();
if (of != nullptr && !of->disabled()) {
i_t prev_fix = node_ptr->orbital_fix_zero.size() + node_ptr->orbital_fix_one.size();
i_t conflicts = of->orbital_fixing(symmetry_, settings_, node_ptr, worker->leaf_problem,
worker->start_lower, worker->start_upper);
i_t new_fix = node_ptr->orbital_fix_zero.size() + node_ptr->orbital_fix_one.size();
if (new_fix > prev_fix) {
++stats.orbital_fixing_nodes;
stats.orbital_fixings_applied += (new_fix - prev_fix);
}
if (conflicts > 0) { ++stats.orbital_conflict_nodes; }
} else if (of != nullptr) {
of->propagate_cumulative_fixings(node_ptr);
}

lp_status = convert_lp_status_to_dual_status(second_status);
if (settings_.symmetry == 2 && worker->lexical_reduction != nullptr) {
i_t lexical_reductions_info =
worker->lexical_reduction->lexical_reduce(symmetry_, node_ptr, worker->leaf_problem);
if (lexical_reductions_info > 0) {
stats.lexical_reduction_nodes++;
stats.lexical_reduction_fixings_applied += lexical_reductions_info;
}
if (lexical_reductions_info == -1) {
feasible = false;
stats.lexical_reduction_pruned_nodes++;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

stats.total_lp_solve_time += toc(lp_start_time);
stats.total_lp_iters += node_iter;
if (feasible) {
i_t node_iter = 0;
f_t lp_start_time = tic();

lp_status = dual_phase2_with_advanced_basis(2,
0,
worker->recompute_basis,
lp_start_time,
worker->leaf_problem,
lp_settings,
leaf_vstatus,
worker->basis_factors,
worker->basic_list,
worker->nonbasic_list,
worker->leaf_solution,
node_iter,
worker->leaf_edge_norms);

if (lp_status == dual::status_t::NUMERICAL) {
log.debug("Numerical issue node %d. Resolving from scratch.\n", node_ptr->node_id);
lp_status_t second_status =
solve_linear_program_with_advanced_basis(worker->leaf_problem,
lp_start_time,
lp_settings,
worker->leaf_solution,
worker->basis_factors,
worker->basic_list,
worker->nonbasic_list,
leaf_vstatus,
worker->leaf_edge_norms);

lp_status = convert_lp_status_to_dual_status(second_status);
}

stats.total_lp_solve_time += toc(lp_start_time);
stats.total_lp_iters += node_iter;
}
}

#ifdef LOG_NODE_SIMPLEX
Expand All @@ -1438,6 +1492,7 @@ void branch_and_bound_t<i_t, f_t>::plunge_with(branch_and_bound_worker_t<i_t, f_
stack.push_front(worker->start_node);
worker->recompute_basis = true;
worker->recompute_bounds = true;
worker->ensure_orbital_fixing();

f_t lower_bound = get_lower_bound();
f_t upper_bound = upper_bound_;
Expand Down Expand Up @@ -1563,6 +1618,7 @@ template <typename i_t, typename f_t>
void branch_and_bound_t<i_t, f_t>::dive_with(branch_and_bound_worker_t<i_t, f_t>* worker)
{
raft::common::nvtx::range scope("BB::diving_thread");
if (worker->orbital_fixing) { worker->orbital_fixing->disable(); }
logger_t log;
log.log = false;

Expand Down Expand Up @@ -1659,7 +1715,7 @@ void branch_and_bound_t<i_t, f_t>::run_scheduler()
std::array<i_t, num_search_strategies> max_num_workers_per_type =
get_max_workers(num_workers, strategies);

worker_pool_.init(num_workers, original_lp_, Arow_, var_types_, settings_);
worker_pool_.init(num_workers, original_lp_, Arow_, var_types_, symmetry_, settings_);
active_workers_per_strategy_.fill(0);

#ifdef CUOPT_LOG_DEBUG
Expand Down Expand Up @@ -1801,7 +1857,7 @@ void branch_and_bound_t<i_t, f_t>::run_scheduler()
template <typename i_t, typename f_t>
void branch_and_bound_t<i_t, f_t>::single_threaded_solve()
{
worker_pool_.init(1, original_lp_, Arow_, var_types_, settings_);
worker_pool_.init(1, original_lp_, Arow_, var_types_, symmetry_, settings_);
branch_and_bound_worker_t<i_t, f_t>* worker = worker_pool_.get_idle_worker();

Comment on lines +1860 to 1862
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Single-thread worker can remain active with stale lower bound after plunge.

single_threaded_solve() now uses worker_pool_, but this path never resets the worker to inactive after plunge_with(worker). Since get_lower_bound() aggregates worker_pool_.get_lower_bound(), stale worker->lower_bound can leak into gap/lower-bound reporting across iterations.

🔧 Proposed fix
   worker->init_best_first(start_node.value(), original_lp_);
   plunge_with(worker);
+  worker->is_active = false;  // prevent stale worker bound from affecting global lower bound
As per coding guidelines, "Validate correct initialization of variable bounds, constraint coefficients, and algorithm state before solving; ensure reset when transitioning between algorithm phases (presolve, simplex, diving, crossover)."

Also applies to: 1860-1861

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cpp/src/branch_and_bound/branch_and_bound.cpp` around lines 1816 - 1818,
single_threaded_solve() takes an idle worker via worker_pool_.get_idle_worker()
and calls plunge_with(worker) but never resets that worker's active state, which
lets a stale worker->lower_bound leak into worker_pool_.get_lower_bound(); after
any call that uses plunge_with(worker) (and the other similar occurrence)
explicitly return the worker to the pool or mark it inactive (e.g., call the
pool release method or
worker->set_inactive()/worker_pool_.release_worker(worker)) so the worker_pool_
no longer aggregates its stale lower_bound; update both the initial usage
(around worker_pool_.init(...) / get_idle_worker() / plunge_with(worker)) and
the later occurrence to ensure workers are reset after plunges.

f_t lower_bound = get_lower_bound();
Expand Down Expand Up @@ -2524,6 +2580,7 @@ mip_status_t branch_and_bound_t<i_t, f_t>::solve(mip_solution_t<i_t, f_t>& solut
basic_list,
nonbasic_list,
basis_update,
symmetry_,
pc_);
}

Expand Down Expand Up @@ -2593,6 +2650,17 @@ mip_status_t branch_and_bound_t<i_t, f_t>::solve(mip_solution_t<i_t, f_t>& solut
node_queue_.push(search_tree_.root.get_down_child());
node_queue_.push(search_tree_.root.get_up_child());

if (symmetry_ != nullptr) {
i_t removed = symmetry_->generators.template prune_by_bounds<f_t>(
original_lp_.lower, original_lp_.upper);
if (removed > 0) {
symmetry_->num_generators = static_cast<int>(symmetry_->generators.num_generators());
settings_.log.printf(
"Pruned %d generators invalidated by root-level bound tightening, %d remain\n",
removed, symmetry_->num_generators);
}
}

settings_.log.printf("Exploring the B&B tree using %d threads\n\n", settings_.num_threads);
node_concurrent_halt_ = 0;

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
23 changes: 23 additions & 0 deletions cpp/src/branch_and_bound/branch_and_bound_worker.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#pragma once

#include <branch_and_bound/mip_node.hpp>
#include <branch_and_bound/symmetry.hpp>

#include <dual_simplex/basis_updates.hpp>
#include <dual_simplex/bounds_strengthening.hpp>
Expand Down Expand Up @@ -46,6 +47,12 @@ struct branch_and_bound_stats_t {
omp_atomic_t<int64_t> total_lp_iters = 0;
omp_atomic_t<i_t> nodes_since_last_log = 0;
omp_atomic_t<f_t> last_log = 0.0;
omp_atomic_t<int64_t> orbital_fixing_nodes = 0;
omp_atomic_t<int64_t> orbital_fixings_applied = 0;
omp_atomic_t<int64_t> orbital_conflict_nodes = 0;
omp_atomic_t<int64_t> lexical_reduction_nodes = 0;
omp_atomic_t<int64_t> lexical_reduction_fixings_applied = 0;
omp_atomic_t<int64_t> lexical_reduction_pruned_nodes = 0;
};

template <typename i_t, typename f_t>
Expand Down Expand Up @@ -73,6 +80,20 @@ class branch_and_bound_worker_t {

pcgenerator_t rng;

std::unique_ptr<orbital_fixing_t<i_t, f_t>> orbital_fixing;
std::unique_ptr<lexical_reduction_t<i_t, f_t>> lexical_reduction;
mip_symmetry_t<i_t, f_t>* symmetry_ptr = nullptr;

void ensure_orbital_fixing()
{
if (orbital_fixing == nullptr && symmetry_ptr != nullptr) {
orbital_fixing = std::make_unique<orbital_fixing_t<i_t, f_t>>(*symmetry_ptr);
}
if (lexical_reduction == nullptr && symmetry_ptr != nullptr) {
lexical_reduction = std::make_unique<lexical_reduction_t<i_t, f_t>>(symmetry_ptr->num_original_vars);
}
}

bool recompute_basis = true;
bool recompute_bounds = true;

Expand Down Expand Up @@ -169,13 +190,15 @@ class branch_and_bound_worker_pool_t {
const lp_problem_t<i_t, f_t>& original_lp,
const csr_matrix_t<i_t, f_t>& Arow,
const std::vector<variable_type_t>& var_type,
mip_symmetry_t<i_t, f_t>* symmetry,
const simplex_solver_settings_t<i_t, f_t>& settings)
{
workers_.resize(num_workers);
num_idle_workers_ = num_workers;
for (i_t i = 0; i < num_workers; ++i) {
workers_[i] = std::make_unique<branch_and_bound_worker_t<i_t, f_t>>(
i, original_lp, Arow, var_type, settings);
workers_[i]->symmetry_ptr = symmetry;
idle_workers_.push_front(i);
}

Expand Down
8 changes: 8 additions & 0 deletions cpp/src/branch_and_bound/mip_node.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ class mip_node_t {
copy.children[1] = nullptr;
copy.status = node_status_t::PENDING;

copy.orbital_fix_zero = orbital_fix_zero;
copy.orbital_fix_one = orbital_fix_one;
copy.origin_worker_id = origin_worker_id;
copy.creation_seq = creation_seq;
return copy;
Expand All @@ -293,6 +295,12 @@ class mip_node_t {

std::vector<variable_status_t> vstatus;

// Cumulative orbital fixing bound changes from root to this node.
// Stored so that when a child starts a new plunge, the parent's
// orbital fixings can be restored without re-derivation.
std::vector<i_t> orbital_fix_zero;
std::vector<i_t> orbital_fix_one;

// Worker-local identification for deterministic ordering:
// - origin_worker_id: which worker created this node
// - creation_seq: sequence number within that worker (cumulative across horizons, serial)
Expand Down
Loading