Initial symmetry detection using dejavu#1103
Conversation
📝 WalkthroughWalkthroughAdds graph-based MIP symmetry detection using the header-only dejavu library, a new symmetry header/structure, and integrates symmetry detection and orbital fixing into the solver and branch-and-bound flow; also updates CMake to fetch dejavu and exposes its include dir to targets and tests. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~40 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
cpp/src/branch_and_bound/symmetry.hpp (1)
62-66: Inconsistent floating-point comparisons for coloring.The code uses
!=for exact comparison of bounds (lines 64-65, 78-79) but uses tolerance-based comparison for objectives (lines 62, 78). This inconsistency could lead to different variables being assigned different colors when they should be equivalent (or vice versa).Consider using tolerance-based comparison consistently:
♻️ Suggested approach
- if (problem.lower[a] != problem.lower[b]) return problem.lower[a] < problem.lower[b]; - if (problem.upper[a] != problem.upper[b]) return problem.upper[a] < problem.upper[b]; + if (std::abs(problem.lower[a] - problem.lower[b]) > tol) return problem.lower[a] < problem.lower[b]; + if (std::abs(problem.upper[a] - problem.upper[b]) > tol) return problem.upper[a] < problem.upper[b];And similarly for the color assignment comparisons on lines 78-79.
Also applies to: 78-79
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cpp/src/branch_and_bound/symmetry.hpp` around lines 62 - 66, The comparisons for bounds in symmetry.hpp are using exact != and < while objectives use tolerance-based comparison, causing inconsistent coloring; update the comparator and the color-assignment code to use a consistent floating-point tolerance (e.g., a local constexpr double tol or an existing epsilon) when comparing problem.objective, problem.lower, and problem.upper so that equality checks use fabs(x-y) <= tol and ordering uses x < y - tol (or x > y + tol) consistently; apply the same tolerant comparisons in the color-assignment branch that currently compares problem.lower and problem.upper and keep integer/enum comparisons (var_types) unchanged to ensure stable, consistent coloring across objective and bound checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@cpp/src/branch_and_bound/symmetry.hpp`:
- Around line 145-146: Guard accesses to nonzero_perm[0] when there are no
nonzeros: before using nonzero_perm[0] (where last_nz and nonzero_colors are
set) check that nnz > 0 and skip the assignment/usage if nnz == 0; apply the
same guard in both the FULL_GRAPH branch (where last_nz =
nonzeros[nonzero_perm[0]] and nonzero_colors[nonzero_perm[0]] = edge_color) and
the default branch (the other occurrence of nonzero_perm[0]). Locate the code in
symmetry.hpp around the nonzero_perm/nonzeros initialization and add a simple if
(nnz > 0) { ... } around these statements so they cannot index into an empty
nonzero_perm array.
- Around line 70-74: The code accesses obj_perm[0] and related arrays without
guarding for an empty problem, which can cause OOB when problem.num_cols == 0;
add an early guard at the start of the routine containing these lines (check
problem.num_cols == 0 or obj_perm.empty()) and return/skip the symmetry setup if
true so no reads of obj_perm[0], problem.objective, problem.lower/upper,
var_types or writes to var_colors occur; ensure the guard is placed before the
block that assigns last_obj, last_lower, last_upper, last_type and
var_colors[obj_perm[0]] = var_color.
In `@cpp/src/mip_heuristics/solve.cu`:
- Around line 310-319: The symmetry detection block (creating detail::problem_t,
simplex_settings, and calling dual_simplex::detect_symmetry) runs before timer_t
is created so its runtime isn't counted toward settings.time_limit and it runs
even when presolve is disabled; move this entire block to after the timer_t
initialization so detect_symmetry uses the started timer and the remaining
time_limit, and additionally wrap the block with a guard that checks
settings.presolver != presolver_t::None (or otherwise only run when presolve is
enabled) so symmetry detection is conditional on the presolver setting.
---
Nitpick comments:
In `@cpp/src/branch_and_bound/symmetry.hpp`:
- Around line 62-66: The comparisons for bounds in symmetry.hpp are using exact
!= and < while objectives use tolerance-based comparison, causing inconsistent
coloring; update the comparator and the color-assignment code to use a
consistent floating-point tolerance (e.g., a local constexpr double tol or an
existing epsilon) when comparing problem.objective, problem.lower, and
problem.upper so that equality checks use fabs(x-y) <= tol and ordering uses x <
y - tol (or x > y + tol) consistently; apply the same tolerant comparisons in
the color-assignment branch that currently compares problem.lower and
problem.upper and keep integer/enum comparisons (var_types) unchanged to ensure
stable, consistent coloring across objective and bound checks.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: a427358a-faac-4982-8eb3-86dd4a92ed37
📒 Files selected for processing (3)
cpp/CMakeLists.txtcpp/src/branch_and_bound/symmetry.hppcpp/src/mip_heuristics/solve.cu
| f_t last_nz = nonzeros[nonzero_perm[0]]; | ||
| nonzero_colors[nonzero_perm[0]] = edge_color; |
There was a problem hiding this comment.
Potential out-of-bounds access when matrix has zero nonzeros.
In both FULL_GRAPH mode (line 145) and the default mode (line 186), nonzero_perm[0] is accessed without checking if nnz > 0. This could occur with a trivial problem containing no constraints or an identity-like constraint matrix after presolve.
🐛 Proposed fix: Guard against zero nonzeros
Add after line 110:
const i_t nnz = problem.A.col_start[problem.num_cols];
+ if (nnz == 0) {
+ settings.log.printf("Skipping symmetry detection: no nonzeros\n");
+ return;
+ }Also applies to: 186-187
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@cpp/src/branch_and_bound/symmetry.hpp` around lines 145 - 146, Guard accesses
to nonzero_perm[0] when there are no nonzeros: before using nonzero_perm[0]
(where last_nz and nonzero_colors are set) check that nnz > 0 and skip the
assignment/usage if nnz == 0; apply the same guard in both the FULL_GRAPH branch
(where last_nz = nonzeros[nonzero_perm[0]] and nonzero_colors[nonzero_perm[0]] =
edge_color) and the default branch (the other occurrence of nonzero_perm[0]).
Locate the code in symmetry.hpp around the nonzero_perm/nonzeros initialization
and add a simple if (nnz > 0) { ... } around these statements so they cannot
index into an empty nonzero_perm array.
There was a problem hiding this comment.
I don't think we can have a problem with zero nonzeros.
There was a problem hiding this comment.
@chris-maes, that's a fair point — a well-formed MIP will always have at least one nonzero in the constraint matrix, and convert_user_problem (which adds slack variables for inequality constraints) further reinforces this. You can disregard the concern.
If you'd like to make this invariant explicit for future readers, a lightweight assert(nnz > 0) after the nnz assignment would serve as self-documenting code, but I understand if you'd prefer to keep it lean.
✏️ Learnings added
Learnt from: chris-maes
Repo: NVIDIA/cuopt PR: 1103
File: cpp/src/branch_and_bound/symmetry.hpp:145-146
Timestamp: 2026-04-15T15:57:46.140Z
Learning: In NVIDIA/cuopt, `detect_symmetry` in `cpp/src/branch_and_bound/symmetry.hpp` is called only from `solve_mip` on well-formed MIP problems. After `convert_user_problem` runs, the constraint matrix `A` is guaranteed to have at least one nonzero (`nnz > 0`), so guards against empty `nonzero_perm` (e.g., checking `nnz == 0` before accessing `nonzero_perm[0]`) are unnecessary in this context.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
🧠 Learnings used
Learnt from: CR
Repo: NVIDIA/cuopt PR: 0
File: .github/.coderabbit_review_guide.md:0-0
Timestamp: 2025-11-25T10:20:49.822Z
Learning: Applies to **/*.{cu,cuh,cpp,hpp,h} : Validate algorithm correctness in optimization logic: simplex pivots, branch-and-bound decisions, routing heuristics, and constraint/objective handling must produce correct results
Learnt from: CR
Repo: NVIDIA/cuopt PR: 0
File: .github/.coderabbit_review_guide.md:0-0
Timestamp: 2025-11-25T10:20:49.822Z
Learning: Applies to **/*.{cu,cuh,cpp,hpp,h} : Validate correct initialization of variable bounds, constraint coefficients, and algorithm state before solving; ensure reset when transitioning between algorithm phases (presolve, simplex, diving, crossover)
Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 721
File: cpp/src/linear_programming/optimization_problem.cu:106-205
Timestamp: 2026-01-29T11:25:28.213Z
Learning: The function `csr_matrices_equivalent_with_permutation` in `cpp/src/linear_programming/optimization_problem.cu` is only used in CI testing with known inputs, so strict problem size validation is not required.
Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 922
File: cpp/src/branch_and_bound/pseudo_costs.cpp:85-87
Timestamp: 2026-03-03T15:03:24.725Z
Learning: In cuOpt's dual simplex solver (cpp/src/dual_simplex/ and cpp/src/branch_and_bound/ files), the dual simplex method is "dual-safe": it maintains dual feasibility throughout execution. Therefore, when the solver terminates early with status ITERATION_LIMIT, the current objective value is still a valid lower bound (for minimization), just looser than the optimal bound. This makes ITERATION_LIMIT results safe to use in strong branching for bound tightening, fixings, and cutoff logic.
Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 899
File: cpp/src/mip_heuristics/solve.cu:169-193
Timestamp: 2026-02-24T14:37:15.024Z
Learning: In cuOpt's MIP solver (cpp/src/mip_heuristics/solve.cu and related files), the objective_scaling_factor sign encodes the user problem's objective sense: if >= 0, the user problem is minimization; if < 0, the user problem is maximization (which is internally converted to minimization by negating the objective). Therefore, checking the sign of objective_scaling_factor is a valid way to determine the problem direction and set appropriate "no bound" values for callbacks.
Learnt from: chris-maes
Repo: NVIDIA/cuopt PR: 500
File: cpp/src/dual_simplex/scaling.cpp:68-76
Timestamp: 2025-12-04T04:11:12.640Z
Learning: In the cuOPT dual simplex solver, CSR/CSC matrices (including the quadratic objective matrix Q) are required to have valid dimensions and indices by construction. Runtime bounds checking in performance-critical paths like matrix scaling is avoided to prevent slowdowns. Validation is performed via debug-only check_matrix() calls wrapped in `#ifdef` CHECK_MATRIX.
Learnt from: CR
Repo: NVIDIA/cuopt PR: 0
File: .github/.coderabbit_review_guide.md:0-0
Timestamp: 2025-11-25T10:20:49.822Z
Learning: Applies to **/*.{cu,cuh,cpp,hpp,h} : Ensure variables and constraints are accessed from the correct problem context (original vs presolve vs folded vs postsolve); verify index mapping consistency across problem transformations
Learnt from: CR
Repo: NVIDIA/cuopt PR: 0
File: .github/.coderabbit_review_guide.md:0-0
Timestamp: 2025-11-25T10:20:49.822Z
Learning: Applies to **/*.{cu,cuh,cpp,hpp,h} : Refactor code duplication in solver components (3+ occurrences) into shared utilities; for GPU kernels, use templated device functions to avoid duplication
Learnt from: chris-maes
Repo: NVIDIA/cuopt PR: 814
File: cpp/src/dual_simplex/cuts.cpp:1278-1278
Timestamp: 2026-01-30T23:34:26.318Z
Learning: In the cuOpt dual simplex solver (cpp/src/dual_simplex/cuts.cpp and related files), slack variable coefficients in the constraint matrix are always exactly 1.0 or -1.0 (not floating-point approximations), so exact equality checks like `assert(std::abs(lp.A.x[col_start]) == 1.0)` are appropriate and should not be flagged as requiring epsilon tolerances.
Learnt from: nguidotti
Repo: NVIDIA/cuopt PR: 1099
File: cpp/src/mip_heuristics/solver.cu:452-459
Timestamp: 2026-04-15T08:55:08.056Z
Learning: In NVIDIA/cuopt, `dual_simplex::branch_and_bound_t<i_t, f_t>::solve()` (called in `cpp/src/mip_heuristics/solver.cu` inside `#pragma omp task`) does not throw C++ exceptions. Therefore, no try-catch / std::exception_ptr wrapper is needed around the call when used inside an OpenMP task region.
Learnt from: CR
Repo: NVIDIA/cuopt PR: 0
File: .github/.coderabbit_review_guide.md:0-0
Timestamp: 2025-11-25T10:20:49.822Z
Learning: Applies to **/*.{cu,cuh,cpp,hpp,h} : Identify assertions with overly strict numerical tolerances that fail on legitimate degenerate/edge cases (near-zero pivots, singular matrices, empty problems)
Learnt from: chris-maes
Repo: NVIDIA/cuopt PR: 599
File: cpp/src/dual_simplex/cuts.cpp:79-99
Timestamp: 2026-01-22T22:55:38.995Z
Learning: In the cuOPT dual simplex cut pool (cpp/src/dual_simplex/cuts.cpp), cuts with zero norm are prevented from entering the cut pool by validation logic elsewhere in the codebase. However, near-zero norms should still be guarded against in functions like cut_orthogonality to ensure numerical stability.
Learnt from: chris-maes
Repo: NVIDIA/cuopt PR: 970
File: cpp/src/cuts/cuts.cpp:626-638
Timestamp: 2026-03-31T19:31:39.942Z
Learning: In NVIDIA/cuopt (cpp/src/cuts/cuts.cpp), `cut_pool_t::add_cut()` always squeezes cuts via `cut.squeeze(cut_squeezed)` before storing them in `cut_storage_`. This guarantees that `cut_storage_` contains no zero coefficients. Therefore, in `cut_pool_t::check_for_duplicate_cuts()`, the CSC matrix derived from `cut_storage_` is free of zero entries, and division by stored coefficients (e.g., `f_r`, `a_ij`) is safe without additional zero-guards.
Learnt from: CR
Repo: NVIDIA/cuopt PR: 0
File: .github/.coderabbit_review_guide.md:0-0
Timestamp: 2025-11-25T10:20:49.822Z
Learning: Applies to **/*.{cu,cuh,cpp,hpp,h} : Verify correct problem size checks before expensive GPU/CPU operations; prevent resource exhaustion on oversized problems
Learnt from: chris-maes
Repo: NVIDIA/cuopt PR: 500
File: cpp/src/dual_simplex/sparse_matrix.cpp:519-524
Timestamp: 2025-12-03T23:29:26.391Z
Learning: In cpp/src/dual_simplex/sparse_matrix.cpp, the check_matrix() function is debug/diagnostic code (wrapped in `#ifdef` CHECK_MATRIX) that intentionally prints errors without necessarily returning early. The return codes from this debug code are not actively checked; the purpose is to print all validation errors in one pass for better diagnostics.
Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 1033
File: cpp/src/mip_heuristics/solve.cu:471-482
Timestamp: 2026-04-03T17:23:30.001Z
Learning: In `cpp/src/mip_heuristics/solve.cu`, the CUOPT_LOG_DEBUG lines after stopping early_gpufj and early_cpufj (e.g. "Early GPU FJ found incumbent...") intentionally log the internal solver-space (minimization) objective via `get_best_objective()`. The user-space objective is already reported by the preceding CUOPT_LOG_INFO "New solution from early primal heuristics" line, so these debug lines deliberately retain the solver-space value for developer debugging purposes and should not be changed to user-space.
Learnt from: CR
Repo: NVIDIA/cuopt PR: 0
File: .github/.coderabbit_review_guide.md:0-0
Timestamp: 2025-11-25T10:20:49.822Z
Learning: Applies to **/*test*.{cpp,cu,py} : Add tests for algorithm phase transitions: verify correct initialization of bounds and state when transitioning from presolve to simplex to diving to crossover
Learnt from: CR
Repo: NVIDIA/cuopt PR: 0
File: .github/.coderabbit_review_guide.md:0-0
Timestamp: 2025-11-25T10:20:49.822Z
Learning: Applies to **/*.{cu,cuh,cpp,hpp,h} : Assess algorithmic complexity for large-scale problems (millions of variables/constraints); ensure O(n log n) or better complexity, not O(n²) or worse
Learnt from: chris-maes
Repo: NVIDIA/cuopt PR: 814
File: cpp/src/dual_simplex/branch_and_bound.hpp:154-159
Timestamp: 2026-01-29T04:25:05.434Z
Learning: In cpp/src/dual_simplex/branch_and_bound.hpp and branch_and_bound.cpp, the mutex_original_lp_ protects original_lp_ specifically during the root cut pass phase. The public API set_new_solution() can be called from external threads during this phase while the main thread is adding cuts/slacks to original_lp_. Functions like best_first_thread, exploration_ramp_up, and plunge_from execute after the cut passes complete (after the OpenMP parallel barrier) when original_lp_ is fixed, so they don't need mutex protection for their reads.
Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 798
File: cpp/src/utilities/timing_utils.hpp:25-29
Timestamp: 2026-01-26T14:18:09.111Z
Learning: In NVIDIA/cuopt, headers under cpp/src should not rely on MSVC-specific features. When reviewing cpp/src/**/*.hpp, verify there is no MSVC-only code paths (e.g., `#ifdef` _MSC_VER, MSVC inline assembly syntax) and prefer portable GCC/Clang-compatible implementations for Linux. If platform-specific code is needed, guard it with portable, clearly documented checks and ensure it compiles with GCC/Clang on Linux. Maintain portability by avoiding MSVC-only fallbacks unless explicitly required by a supported build configuration.
Learnt from: nguidotti
Repo: NVIDIA/cuopt PR: 956
File: cpp/src/branch_and_bound/reduced_cost_fixing.hpp:46-69
Timestamp: 2026-03-16T12:53:42.340Z
Learning: This learning notes that variable_type_t::BINARY (value 1) is unused at runtime in NVIDIA/cuopt and that binary variables are represented as variable_type_t::INTEGER (value 2). Review comments about missing BINARY handling in MIP-related code (e.g., in the branch_and_bound directory) are not applicable in practice. For code reviews, apply this understanding to headers and related MIP/branch-and-bound implementations under cpp/src/branch_and_bound, and avoid flagging or requiring changes specifically for BINARY handling in this area unless CI or tests indicate a separate, verifiable issue.
Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 986
File: cpp/src/branch_and_bound/branch_and_bound.cpp:8-8
Timestamp: 2026-03-23T11:33:23.998Z
Learning: In this repo (NVIDIA/cuopt), treat nvcc as the supported CUDA toolchain; clang-based compilation/support is not required and may fail/break. During code reviews, do NOT request code changes or add blocking comments for errors that appear only under clang (e.g., header-resolution failures such as 'utilities/determinism_log.hpp not found')—these can be toolchain-related rather than real source issues.
| // Start symmetry detection | ||
| { | ||
| detail::problem_t<i_t, f_t> problem(op_problem); | ||
| dual_simplex::simplex_solver_settings_t<i_t, f_t> simplex_settings; | ||
| simplex_settings.set_log(true); | ||
| simplex_settings.time_limit = settings.time_limit; | ||
| dual_simplex::user_problem_t<i_t, f_t> user_problem = | ||
| cuopt_problem_to_simplex_problem<i_t, f_t>(op_problem.get_handle_ptr(), problem); | ||
| dual_simplex::detect_symmetry(user_problem, simplex_settings); | ||
| } |
There was a problem hiding this comment.
Symmetry detection runs before timer starts, affecting time accounting.
The symmetry detection block executes before timer_t is created on line 321. This means:
- Time spent in
detect_symmetryis not tracked againsttime_limit - The
simplex_settings.time_limitpassed todetect_symmetrydoesn't account for any prior setup time
Additionally, symmetry detection runs unconditionally, even when presolve is disabled via settings.presolver == presolver_t::None.
Consider moving this block after timer initialization or accounting for its duration, and potentially gating it on presolve settings if appropriate.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@cpp/src/mip_heuristics/solve.cu` around lines 310 - 319, The symmetry
detection block (creating detail::problem_t, simplex_settings, and calling
dual_simplex::detect_symmetry) runs before timer_t is created so its runtime
isn't counted toward settings.time_limit and it runs even when presolve is
disabled; move this entire block to after the timer_t initialization so
detect_symmetry uses the started timer and the remaining time_limit, and
additionally wrap the block with a guard that checks settings.presolver !=
presolver_t::None (or otherwise only run when presolve is enabled) so symmetry
detection is conditional on the presolver setting.
|
Wow, those are some good numbers indeed 🙂 Thanks a lot Chris!! |
|
Maybe @aliceb-nv would be a better reviewer for this than me? (Looks like I got pulled in by auto-assignment) |
Good question @aliceb-nv , I think it is more difficult to construct the graph to compute the automorphism using the representation in problem_t. In particular the lower and upper bounds on the constraints complicate things. My strategy was to extract the symmetries in the variables from the symmetries computed on the graph. I think once we have symmetries on the variables, we can use them inside presolve. Note that we may need to turn off some (perhaps all) of presolve if we detect symmetries. |
…a bug in branch and bound.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@cpp/src/branch_and_bound/branch_and_bound.cpp`:
- Around line 1396-1547: Deterministic B&B never runs the orbital-fixing block
(it only runs in solve_node_lp), so either move the orbital-fixing logic into a
reused helper and call it from solve_node_deterministic() and
deterministic_dive(), or disable symmetry_ in deterministic mode; specifically,
extract the symmetry handling/fixing code currently inside the if (symmetry_ !=
nullptr) block into a new function (e.g., perform_orbital_fixing(node_ptr,
worker) or symmetry_->apply_orbital_fixing(...)) and invoke that helper from
solve_node_deterministic() and deterministic_dive() (or set symmetry_ = nullptr
/ skip symmetry setup when deterministic) so deterministic runs no longer pay
overhead without applying fixes. Ensure the helper uses the same symbols
(node_ptr, worker, symmetry_, var_types_, settings_.log) and preserves all
marking/restore semantics.
- Around line 1396-1547: The code mutates shared symmetry_ state
(symmetry_->marked_b0/marked_b1/marked_f0/marked_f1, symmetry_->orbit_has_*,
symmetry_->f0/f1 and calls symmetry_->schreier->set_base) while solve_node_lp()
runs concurrently, causing races; fix by making all scratch state worker-local
and avoid mutating the shared Schreier base: allocate per-worker vectors (e.g.
local_marked_b0, local_marked_b1, local_marked_f0, local_marked_f1,
local_orbit_has_*, local_f0, local_f1) sized symmetry_->num_original_vars and
use those instead of symmetry_->marked_* and symmetry_->orbit_has_*, and either
use a worker-local copy/clone of symmetry_->schreier (or ask Schreier for a
temporary stabilizer/orbit API) and call set_base on that copy; ensure you
update worker-local data when computing orb and apply fixings to
worker->leaf_problem as before; if cloning schreier is impossible, protect the
set_base/get_stabilizer_orbit sequence with a mutex around symmetry_->schreier
to prevent concurrent mutation.
In `@cpp/src/branch_and_bound/symmetry.hpp`:
- Around line 405-430: The code uses orb.orbit_size(j) (which counts all
vertices including slacks/aux) to build orbit_histogram and compute
max_orbit_size/total_vars_in_orbits; this can exceed num_original_vars and
corrupt orbit_histogram. Fix by counting only original variables per orbit
representative: when orb.represents_orbit(j) is true, accumulate a count per
representative (e.g., a map keyed by j) by incrementing for each original index
j, then after collecting counts use that representative-count as sz_original
(bounded by num_original_vars) to update orbit_histogram, max_orbit_size, and
total_vars_in_orbits, and base the has_symmetry test on those original-variable
counts instead of orb.orbit_size(j).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 994d1ff5-8392-4cf9-9414-93016c15e887
📒 Files selected for processing (8)
cpp/src/branch_and_bound/branch_and_bound.cppcpp/src/branch_and_bound/branch_and_bound.hppcpp/src/branch_and_bound/symmetry.hppcpp/src/mip_heuristics/diversity/diversity_manager.cucpp/src/mip_heuristics/solve.cucpp/src/mip_heuristics/solver.cucpp/src/mip_heuristics/solver_context.cuhcpp/tests/CMakeLists.txt
✅ Files skipped from review due to trivial changes (2)
- cpp/src/mip_heuristics/diversity/diversity_manager.cu
- cpp/tests/CMakeLists.txt
🚧 Files skipped from review as they are similar to previous changes (1)
- cpp/src/mip_heuristics/solve.cu
| // 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; | ||
| } | ||
|
|
||
| { | ||
| 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; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Orbital fixing is currently skipped in deterministic B&B.
The new logic only runs in solve_node_lp(). Deterministic mode solves nodes through solve_node_deterministic() and deterministic_dive(), which never touch symmetry_, so that codepath gets symmetry detection overhead but no orbital-fixing benefit. Please either apply the same fixing there or disable symmetry handling for deterministic runs until it is supported end-to-end.
🤖 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 1396 - 1547,
Deterministic B&B never runs the orbital-fixing block (it only runs in
solve_node_lp), so either move the orbital-fixing logic into a reused helper and
call it from solve_node_deterministic() and deterministic_dive(), or disable
symmetry_ in deterministic mode; specifically, extract the symmetry
handling/fixing code currently inside the if (symmetry_ != nullptr) block into a
new function (e.g., perform_orbital_fixing(node_ptr, worker) or
symmetry_->apply_orbital_fixing(...)) and invoke that helper from
solve_node_deterministic() and deterministic_dive() (or set symmetry_ = nullptr
/ skip symmetry setup when deterministic) so deterministic runs no longer pay
overhead without applying fixes. Ensure the helper uses the same symbols
(node_ptr, worker, symmetry_, var_types_, settings_.log) and preserves all
marking/restore semantics.
Make orbital-fixing scratch state worker-local.
This block mutates symmetry_->marked_*, symmetry_->orbit_has_*, symmetry_->f0/f1, and also calls symmetry_->schreier->set_base(...) on the shared symmetry_ object. solve_node_lp() runs concurrently across opportunistic B&B workers, so this is a real data race that can corrupt fixings or the Schreier state across nodes. At minimum this needs synchronization; preferably move the scratch vectors and rebased Schreier state into per-worker storage instead of sharing them globally.
As per coding guidelines, "Ensure race conditions are absent in multi-GPU code and multi-threaded server implementations; verify proper synchronization of shared state."
🤖 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 1396 - 1547, The
code mutates shared symmetry_ state
(symmetry_->marked_b0/marked_b1/marked_f0/marked_f1, symmetry_->orbit_has_*,
symmetry_->f0/f1 and calls symmetry_->schreier->set_base) while solve_node_lp()
runs concurrently, causing races; fix by making all scratch state worker-local
and avoid mutating the shared Schreier base: allocate per-worker vectors (e.g.
local_marked_b0, local_marked_b1, local_marked_f0, local_marked_f1,
local_orbit_has_*, local_f0, local_f1) sized symmetry_->num_original_vars and
use those instead of symmetry_->marked_* and symmetry_->orbit_has_*, and either
use a worker-local copy/clone of symmetry_->schreier (or ask Schreier for a
temporary stabilizer/orbit API) and call set_base on that copy; ensure you
update worker-local data when computing orb and apply fixings to
worker->leaf_problem as before; if cloning schreier is impossible, protect the
set_base/get_stabilizer_orbit sequence with a mutex around symmetry_->schreier
to prevent concurrent mutation.
| std::vector<i_t> orbit_histogram(num_original_vars + 1, 0); | ||
| for (i_t j = 0; j < num_original_vars; j++) { | ||
| if (orb.represents_orbit(j)) { | ||
| i_t sz = orb.orbit_size(j); | ||
| if (sz >= 2) { | ||
| num_nontrivial_orbits++; | ||
| max_orbit_size = std::max(max_orbit_size, sz); | ||
| total_vars_in_orbits += sz; | ||
| orbit_histogram[sz]++; | ||
| } | ||
| } | ||
| } | ||
| settings.log.printf("Orbits: %d non-trivial, max size %d, %d/%d (%.1f%%) variables in orbits\n", | ||
| num_nontrivial_orbits, max_orbit_size, total_vars_in_orbits, num_original_vars, | ||
| 100.0 * total_vars_in_orbits / num_original_vars); | ||
| settings.log.printf("Orbit histogram (orbit size: number of orbits):"); | ||
| for (i_t sz = 2; sz <= max_orbit_size; sz++) { | ||
| if (orbit_histogram[sz] > 0) { | ||
| settings.log.printf(" %d:%d", sz, orbit_histogram[sz]); | ||
| } | ||
| } | ||
| settings.log.printf("\n"); | ||
|
|
||
| has_symmetry = (max_orbit_size >= 4) || | ||
| (num_nontrivial_orbits >= 3 && max_orbit_size >= 2) || | ||
| (total_vars_in_orbits >= 10); |
There was a problem hiding this comment.
Count original-variable orbit sizes before indexing the histogram.
orb.orbit_size(j) is the size of the full graph orbit, not the number of original variables in it. If an original variable shares an orbit with slack or auxiliary vertices, sz can exceed num_original_vars, so orbit_histogram[sz]++ writes past the vector. It also inflates max_orbit_size and total_vars_in_orbits, which makes the has_symmetry heuristic trigger on non-original vertices.
💡 Minimal direction for a fix
- std::vector<i_t> orbit_histogram(num_original_vars + 1, 0);
- for (i_t j = 0; j < num_original_vars; j++) {
- if (orb.represents_orbit(j)) {
- i_t sz = orb.orbit_size(j);
+ std::vector<i_t> orbit_original_size(num_vertices, 0);
+ std::vector<char> seen_rep(num_vertices, 0);
+ for (i_t j = 0; j < num_original_vars; ++j) {
+ orbit_original_size[orb.find_orbit(j)]++;
+ }
+
+ std::vector<i_t> orbit_histogram(num_original_vars + 1, 0);
+ for (i_t j = 0; j < num_original_vars; ++j) {
+ const i_t rep = orb.find_orbit(j);
+ if (seen_rep[rep]) { continue; }
+ seen_rep[rep] = 1;
+ i_t sz = orbit_original_size[rep];
if (sz >= 2) {
num_nontrivial_orbits++;
max_orbit_size = std::max(max_orbit_size, sz);
total_vars_in_orbits += sz;
orbit_histogram[sz]++;
}
- }
}As per coding guidelines, "Validate algorithm correctness in optimization logic: simplex pivots, branch-and-bound decisions, routing heuristics, and constraint/objective handling must produce correct results."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@cpp/src/branch_and_bound/symmetry.hpp` around lines 405 - 430, The code uses
orb.orbit_size(j) (which counts all vertices including slacks/aux) to build
orbit_histogram and compute max_orbit_size/total_vars_in_orbits; this can exceed
num_original_vars and corrupt orbit_histogram. Fix by counting only original
variables per orbit representative: when orb.represents_orbit(j) is true,
accumulate a count per representative (e.g., a map keyed by j) by incrementing
for each original index j, then after collecting counts use that
representative-count as sz_original (bounded by num_original_vars) to update
orbit_histogram, max_orbit_size, and total_vars_in_orbits, and base the
has_symmetry test on those original-variable counts instead of
orb.orbit_size(j).
This PR add supports for symmetry detection in MIP.
We construct a colored graph for MIP problems that can be used with a graph automorphism solver (in this case dejavu) to detect symmetries.
Below we compare with the paper: A Computational Comparison of Symmetry Handling Methods for Mixed Integer Programs
https://optimization-online.org/wp-content/uploads/2015/11/5209.pdf