Skip to content

Integrate PSLP presolver in cuOpt#816

Open
rg20 wants to merge 49 commits intoNVIDIA:release/26.02from
rg20:integrate_pslp
Open

Integrate PSLP presolver in cuOpt#816
rg20 wants to merge 49 commits intoNVIDIA:release/26.02from
rg20:integrate_pslp

Conversation

@rg20
Copy link
Contributor

@rg20 rg20 commented Jan 29, 2026

Description

This PR adds support for using PSLP presolver for LPs. The PSLP presolver is enabled by default for LPs. This can be changed to off (--presolve 0) or switch to papilo (--presolve 1).

This PR also fixes a bug and improves performance of the folding.
Bug fix:
Coloring scheme was resulting in AX != YA because of incorrect optimization step. This is now fixed.

Performance improvement:
Replaced std::unordered_map with std::map. The unordered map required computing hash values on floating point numbers which is very slow. With this improvement dlr1 model can be folded in 6 seconds compared 30 seconds before. dlr2 can be folded now in 30 seconds.

Issue

Checklist

  • I am familiar with the Contributing Guidelines.
  • Testing
    • New or existing tests cover these changes
    • Added tests
    • Created an issue to follow-up
    • NA
  • Documentation
    • The documentation is up to date with these changes
    • Added new documentation
    • NA

Summary by CodeRabbit

  • New Features

    • PSLP added as a selectable LP presolver (becomes the LP default); Papilo remains for MIP.
  • Refactor

    • Replaced boolean presolve flags with a presolver enum (Default/None/Papilo/PSLP).
    • Solver timing simplified to per-stage timing; optional start-time overloads added.
  • API

    • New presolve constants and presolver field in public settings; solver call signatures extended/overloaded.
  • Documentation

    • FAQ and settings docs updated for presolver choices and defaults.
  • Tests

    • New unit tests validating PSLP presolve/postsolve.
  • Chores

    • Build/test include paths, license updates, and benchmark CLI tolerances added.

@rg20 rg20 requested review from a team as code owners January 29, 2026 15:53
@rg20 rg20 requested review from Iroy30 and gforsyth January 29, 2026 15:53
@copy-pr-bot
Copy link

copy-pr-bot bot commented Jan 29, 2026

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@rg20 rg20 requested review from akifcorduk and chris-maes January 29, 2026 15:53
@rg20 rg20 added feature request New feature or request non-breaking Introduces a non-breaking change labels Jan 29, 2026
@rg20 rg20 added this to the 26.02 milestone Jan 29, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 29, 2026

📝 Walkthrough

Walkthrough

Replaces boolean presolve flags with a new presolver_t enum across settings, solvers, tests and docs; integrates PSLP as a static FetchContent dependency and exposes its headers; adds PSLP presolve plumbing and cleanup; refactors folding to ordered maps and status returns; fixes histogram/dense-row logic; adds LP solver overloads.

Changes

Cohort / File(s) Summary
Build & project config
cpp/CMakeLists.txt, cpp/tests/CMakeLists.txt, cpp/tests/linear_programming/CMakeLists.txt
Add PSLP via FetchContent (v0.0.4), force static build, link PSLP into cuopt by file path, and expose PSLP include dirs to targets and tests.
Public constants & enum
cpp/include/cuopt/linear_programming/constants.h, cpp/include/cuopt/linear_programming/utilities/internals.hpp
Add CUOPT_PRESOLVE_* macros and presolver_t enum (Default, None, Papilo, PSLP).
Solver settings API
cpp/include/.../mip/solver_settings.hpp, cpp/include/.../pdlp/solver_settings.hpp
Replace public presolve bool with presolver_t presolver (default Default); add required include updates.
Presolve plumbing across solvers
cpp/src/linear_programming/solve.cu, cpp/src/mip/solve.cu, cpp/src/mip/solver.cu, cpp/src/mip/diversity/diversity_manager.cu, cpp/src/math_optimization/solver_settings.cu, cpp/src/mip/relaxed_lp/relaxed_lp.cu
Propagate and normalize presolver_t across LP/MIP flows, map Default→concrete choices, replace boolean checks with presolver != presolver_t::None, pass presolver into presolve calls, and set None where presolve must be skipped.
PSLP integration & presolve implementation
cpp/src/mip/presolve/third_party_presolve.hpp, cpp/src/mip/presolve/third_party_presolve.cpp
Introduce PSLP scaffolding and state (PSLPContext, Settings*/Presolver*), add apply_pslp/undo_pslp, change apply(...) to accept presolver_t, add destructor and resource cleanup, and translate PSLP results into optimization problems.
Dual-simplex fixes & folding refactor
cpp/src/dual_simplex/barrier.cu, cpp/src/dual_simplex/folding.cpp
Fix histogram iteration and dense-row counting; convert std::unordered_mapstd::map; change find_colors_to_split/split_colors to return status (i_t); implement Paige–Tarjan largest-part retention, sum-zero handling, and expanded debug/verification scaffolding.
LP solve API overloads
cpp/src/dual_simplex/solve.cpp, cpp/src/dual_simplex/solve.hpp
Add overloads that compute start_time internally for solve_linear_program and solve_linear_program_with_barrier and provide explicit template instantiations.
MIP solve normalization & local copy
cpp/src/mip/solve.cu
Rename parameter to settings_const, construct local settings copy for normalization, translate Default/PSLP→Papilo for MIP with a notice, and pass presolver into presolve flows.
Tests & test utilities
cpp/tests/*, cpp/tests/mip/mip_utils.cuh, cpp/tests/linear_programming/unit_tests/presolve_test.cu, cpp/tests/linear_programming/pdlp_test.cu, various test files
Update tests and helpers to use presolver_t instead of bool presolve; adjust function signatures, call sites and defaults; add extensive PSLP unit tests and include PSLP headers in test include paths.
Docs & scripts
docs/cuopt/source/*.rst, benchmarks/linear_programming/run_mps_files.sh
Document presolver options and defaults (CUOPT_PRESOLVE) for LP/MIP; add PDLP tolerances CLI option and propagate tolerance flags in the benchmark script.
Misc & licensing
thirdparty/THIRD_PARTY_LICENSES, various headers
Replace Papilo license blocks with PSLP Apache-2.0 entries; update copyright years and minor timing/logging adjustments.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.98% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and concisely describes the main change: integrating PSLP presolver into cuOpt.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
cpp/tests/CMakeLists.txt (1)

40-46: Remove the hard-coded PSLP include path.

The absolute /home/nfs/... path will break builds on any other machine/CI. Please use the PSLP target’s include dirs or FetchContent variables.

🔧 Suggested fix (target-based include)
-        "/home/nfs/rgandham/git-repos/PSLP/include/PSLP"
+        "$<TARGET_PROPERTY:PSLP,INTERFACE_INCLUDE_DIRECTORIES>"
cpp/src/mip/presolve/third_party_presolve.cpp (1)

629-691: Add null check and postsolve error handling in undo_pslp.

The undo_pslp method dereferences pslp_presolver_ without validation (lines 682, 689–691) and doesn't check the postsolve() result. In contrast, the Papilo postsolve path explicitly checks status via check_postsolve_status() (line 655). Add a guard assertion and status check to match the error handling pattern used for Papilo:

🧯 Guard against uninitialized PSLP state
 void third_party_presolve_t<i_t, f_t>::undo_pslp(rmm::device_uvector<f_t>& primal_solution,
                                                  rmm::device_uvector<f_t>& dual_solution,
                                                  rmm::device_uvector<f_t>& reduced_costs,
                                                  rmm::cuda_stream_view stream_view)
 {
+  cuopt_expects(pslp_presolver_ != nullptr,
+                error_type_t::RuntimeError,
+                "PSLP presolver not initialized");
   std::vector<f_t> h_primal_solution(primal_solution.size());
🤖 Fix all issues with AI agents
In `@cpp/src/dual_simplex/barrier.cu`:
- Around line 1155-1158: The histogram output loop omits the last bin (nnz == n)
because histogram_row has size n+1 but the for loop uses "for (i_t k = 0; k < n;
k++)"; update the loop that prints histogram_row (the block using
settings.log.printf and the loop variable k) to iterate through all n+1 bins
(e.g., change the loop condition to include k == n) so entries where row_nz == n
are printed.

In `@cpp/src/math_optimization/solver_settings.cu`:
- Around line 91-93: The CUOPT_PRESOLVE parameter is now treated as an int but
callers (e.g., set_parameter_from_string or boolean setters) may still pass
"true"/"false" or bools; update the parameter parsing/shim to accept
boolean-like values and map them to the int enums (e.g., DEFAULT/OFF) before
assigning to pdlp_settings.presolver and mip_settings.presolver, or add a branch
in the string-to-parameter logic that recognizes "true"/"false"/"1"/"0" and
converts them to the corresponding CUOPT_PRESOLVE_* constants so existing
boolean-based calls do not throw when setting CUOPT_PRESOLVE.

In `@cpp/src/mip/presolve/third_party_presolve.cpp`:
- Around line 528-539: Before overwriting pslp_presolver_ and pslp_stgs_ in
third_party_presolve_t<i_t,f_t>::apply_pslp, check if they are non-null and
release their prior allocations (call the appropriate PSLP destructor/free
function or delete depending on how PSLP objects are managed), then set them to
the new ctx.presolver and ctx.settings; specifically, free the existing
pslp_presolver_ and pslp_stgs_ members before assigning new values to avoid
leaking prior PSLP resources when apply_pslp is called repeatedly.
- Line 8: Remove the unused include of boost/serialization/void_cast.hpp from
third_party_presolve.cpp: delete the line "#include
<boost/serialization/void_cast.hpp>" (ensure no other code in this file
references Boost Serialization or void_cast so the build remains clean).
- Around line 180-283: Replace the assert and ignored presolve result in
build_and_run_pslp_presolver with explicit runtime checks: change the function
to return std::optional<PSLPContext>, after calling new_presolver(...) check if
ctx.presolver is nullptr and return std::nullopt (cleaning up settings if
needed); then call run_presolver(ctx.presolver) and if the returned
PresolveStatus is not success (explicitly handle kInfeasible, kUnbndOrInfeas and
any non-success), free/delete ctx.presolver and return std::nullopt; only return
a populated PSLPContext when new_presolver succeeded and run_presolver returned
success, ensuring callers (e.g., code reading ctx.presolver->reduced_prob) won't
dereference an invalid presolver.

In `@cpp/src/mip/presolve/third_party_presolve.hpp`:
- Around line 58-76: The class currently owning raw pointers pslp_stgs_ and
pslp_presolver_ can be accidentally copied and cause double-free; update the
third_party_presolve_t class to be non-copyable/non-movable by explicitly
deleting the copy constructor, copy assignment operator, move constructor, and
move assignment operator (i.e., add declarations like
third_party_presolve_t(const third_party_presolve_t&) = delete; and similar for
operator= and move variants) so instances that own pslp_stgs_ and
pslp_presolver_ cannot be copied or moved; alternatively, replace those raw
pointers with RAII types (unique_ptr) if you prefer move-only semantics, but the
quick fix is to delete copy/move for third_party_presolve_t.

In `@cpp/src/mip/solve.cu`:
- Around line 150-153: The code in the mip_solver_settings_t constructor block
currently silently replaces settings.presolver when it's presolver_t::Default or
presolver_t::PSLP with presolver_t::Papilo; update this to either emit a clear
warning or return an error instead of silently overriding: detect when
settings.presolver == presolver_t::PSLP (and optionally when ==
presolver_t::Default), log a warning via the existing logging mechanism (or
propagate an error/exception) that "PSLP not supported for MIP, falling back to
Papilo" (or require user change), and only perform the assignment to
presolver_t::Papilo after logging/handling so the change is explicit; refer to
mip_solver_settings_t, settings.presolver and presolver_t::PSLP /
presolver_t::Papilo when locating and modifying the code.
🧹 Nitpick comments (2)
benchmarks/linear_programming/run_mps_files.sh (1)

417-417: Make tolerances overrideable (and verify CLI flag support).
Hard-coding 1.0e-6 across all runs limits benchmarking flexibility; consider env-based defaults so users can adjust without editing the script. Also please confirm these flags are supported by the cuopt_cli version used in CI to avoid runtime failures.

♻️ Example with env-defaults (single-line change)
-        CUDA_VISIBLE_DEVICES=$gpu_devices cuopt_cli "$mps_file" --time-limit $TIME_LIMIT $args --relative-primal-tolerance 1.0e-6 --relative-dual-tolerance 1.0e-6 --relative-gap-tolerance 1.0e-6 --absolute-primal-tolerance 1.0e-6 --absolute-dual-tolerance 1.0e-6 --absolute-gap-tolerance 1.0e-6
+        CUDA_VISIBLE_DEVICES=$gpu_devices cuopt_cli "$mps_file" --time-limit $TIME_LIMIT $args \
+          --relative-primal-tolerance ${RELATIVE_PRIMAL_TOLERANCE:-1.0e-6} \
+          --relative-dual-tolerance ${RELATIVE_DUAL_TOLERANCE:-1.0e-6} \
+          --relative-gap-tolerance ${RELATIVE_GAP_TOLERANCE:-1.0e-6} \
+          --absolute-primal-tolerance ${ABSOLUTE_PRIMAL_TOLERANCE:-1.0e-6} \
+          --absolute-dual-tolerance ${ABSOLUTE_DUAL_TOLERANCE:-1.0e-6} \
+          --absolute-gap-tolerance ${ABSOLUTE_GAP_TOLERANCE:-1.0e-6}
cpp/src/dual_simplex/folding.cpp (1)

224-280: Guard non‑finite sums before using them as std::map keys.

Line 247 and Line 279 insert floating sums into ordered maps; if any sum is NaN/Inf, std::map’s ordering is violated and keys can collapse, leading to incorrect splits. Consider a lightweight finiteness check before insertion.

🛠️ Suggested guard
   sum_to_sizes.clear();
   color_sums.clear();
   for (i_t v : vertices_to_refine_by_color[color]) {
-    sum_to_sizes[vertex_to_sum[v]]++;
+    const f_t sum = vertex_to_sum[v];
+    if (!std::isfinite(sum)) {
+      printf("Folding: non-finite sum for vertex %d\n", v);
+      exit(1);
+    }
+    sum_to_sizes[sum]++;
   }

As per coding guidelines: Check numerical stability: prevent overflow/underflow, precision loss, division by zero/near-zero, and use epsilon comparisons for floating-point equality checks.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cpp/tests/linear_programming/pdlp_test.cu (1)

1615-1615: Inconsistent API usage: presolve boolean instead of presolver enum.

This line still uses the old settings.presolve = false pattern while the rest of the codebase has migrated to settings.presolver = presolver_t::None. This appears to be a missed migration.

🛠️ Proposed fix
-  solver_settings.presolve         = false;
+  solver_settings.presolver        = presolver_t::None;
🤖 Fix all issues with AI agents
In `@cpp/src/mip/presolve/third_party_presolve.cpp`:
- Around line 682-691: undo_pslp() is resizing and copying solution outputs
using the reduced problem dimensions (pslp_presolver_->reduced_prob->m/n) which
truncates postsolved outputs; instead, capture the original problem dimensions
in apply_pslp() (e.g., store original m and n on the pslp_presolver_ object or a
dedicated members like orig_m/orig_n when you create reduced_prob) and then use
those stored original dimensions in undo_pslp() when resizing primal_solution,
dual_solution, reduced_costs and when copying from pslp_presolver_->sol->x/y/z
so the postsolve-expanded solutions fit the original problem size.

In `@cpp/tests/CMakeLists.txt`:
- Line 45: Remove the hard-coded absolute include path
"/home/nfs/rgandham/git-repos/PSLP/include/PSLP" from the
target_include_directories call in cpp/tests/CMakeLists.txt; the test target
already links to cuopt (which has PSLP as a PRIVATE dependency via
FetchContent), so simply delete that literal entry from
target_include_directories to rely on transitive includes from the cuopt target.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cpp/src/linear_programming/solve.cu (1)

1146-1162: Presolve time log is now misleading for PSLP.
The message still says “Papilo presolve time” even when PSLP is selected, which makes logs confusing.

🔧 Proposed change
-      CUOPT_LOG_INFO("Papilo presolve time: %f", presolve_time);
+      CUOPT_LOG_INFO("Presolve time: %f", presolve_time);
🧹 Nitpick comments (1)
cpp/tests/linear_programming/pdlp_test.cu (1)

214-237: Add PSLP (and optionally Default) coverage in the presolver loop.

This test is the most visible place to exercise the new presolver options; currently only Papilo/None are covered. Adding PSLP here would validate the new integration path.

🔧 Suggested test expansion
-      for (auto [presolver, epsilon] :
-           {std::pair{presolver_t::Papilo, 1e-1}, std::pair{presolver_t::None, 1e-6}}) {
+      for (auto [presolver, epsilon] :
+           {std::pair{presolver_t::Papilo, 1e-1},
+            std::pair{presolver_t::PSLP, 1e-1},
+            std::pair{presolver_t::None, 1e-6}}) {

@nguidotti
Copy link
Contributor

I have a question: the PSLP will be only applicable for LP or it will be also be used for MIP as well?

if (!largest_is_sum_zero && color_sums.count(0.0) > 0) {
// sum=0 exists but is NOT the largest - need to create a new color for it
// These are vertices in the color that don't touch the refining color
std::unordered_set<i_t> refined_set(vertices_to_refine_by_color[color].begin(),
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't need to create a refined_set to know if a vertex was not touched by the refining color.

std::unordered_set<i_t> refined_set(vertices_to_refine_by_color[color].begin(),
vertices_to_refine_by_color[color].end());
for (i_t v : colors[color].vertices) {
if (refined_set.find(v) == refined_set.end()) { sum_zero_vertices.push_back(v); }
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this condition should be:

if (vertex_to_sum[v] == 0.0) { sum_zero_vertices.push_back(v); } 

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, we don't need to have a separate sum_zero_vertices array at all. We should just append these to color_sums

if (vertex_to_sum[v] == 0.0) { color_sums[0.0].push_back(v) } 

@rg20
Copy link
Contributor Author

rg20 commented Feb 6, 2026

/ok to test dceac9a

@rg20
Copy link
Contributor Author

rg20 commented Feb 6, 2026

/ok to test 528ddea

@rg20
Copy link
Contributor Author

rg20 commented Feb 6, 2026

@rg20 PLease add PSLP license details to https://github.com/NVIDIA/cuopt/blob/main/thirdparty/THIRD_PARTY_LICENSES

done

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cpp/src/dual_simplex/folding.cpp (1)

251-266: ⚠️ Potential issue | 🟡 Minor

Unsigned subtraction of size_t values may not be caught by the < 0 check.

colors[color].vertices.size() and vertices_to_refine_by_color[color].size() both return size_t. The subtraction is performed in unsigned arithmetic, so if the second operand is larger, the result wraps to a huge positive value. Assigning this to i_t (signed int) is implementation-defined and the remaining_size < 0 check may not reliably detect the error condition.

Proposed fix: cast before subtraction
-    const i_t remaining_size =
-      colors[color].vertices.size() - vertices_to_refine_by_color[color].size();
+    const i_t remaining_size =
+      static_cast<i_t>(colors[color].vertices.size()) - static_cast<i_t>(vertices_to_refine_by_color[color].size());

As per coding guidelines: "Validate algorithm correctness in optimization logic... constraint/objective handling must produce correct results."

🤖 Fix all issues with AI agents
In `@cpp/src/dual_simplex/folding.cpp`:
- Around line 711-712: The log call using settings.log.printf in folding.cpp
(the line that prints "Folding: %d refinements %d colors in %.2fs") is missing a
trailing newline; update that settings.log.printf invocation to include a "\n"
at the end of the format string so the message ends with a newline (keep the
same format specifiers and arguments: num_refinements, current_colors, elapsed).
🧹 Nitpick comments (1)
cpp/src/dual_simplex/folding.cpp (1)

1440-1451: operator[] on unordered_map silently inserts default entries during verification.

Lines 1441 and 1447–1448 use operator[] on row_col_color_sum, which inserts a default 0.0 entry if the key doesn't exist. This is functionally correct (missing entries represent zero sums), but it mutates the lookup maps during what is conceptually a read-only check, bloating memory. The column-side check at line 1481 correctly uses .find() instead. Since this is DEBUG-only code, this is low priority.

Use `.find()` or `.count()` consistently
       for (auto& [cc, ref_sum] : row_col_color_sum[ref_row]) {
-        f_t u_sum = row_col_color_sum[u][cc];
+        f_t u_sum = 0.0;
+        auto it_u = row_col_color_sum[u].find(cc);
+        if (it_u != row_col_color_sum[u].end()) { u_sum = it_u->second; }
         f_t diff  = std::abs(ref_sum - u_sum);

Similarly for line 1447–1448:

       for (auto& [cc, u_sum] : row_col_color_sum[u]) {
-        f_t ref_sum = row_col_color_sum[ref_row][cc];
+        f_t ref_sum = 0.0;
+        auto it_r = row_col_color_sum[ref_row].find(cc);
+        if (it_r != row_col_color_sum[ref_row].end()) { ref_sum = it_r->second; }
         f_t diff    = std::abs(ref_sum - u_sum);

@chris-maes
Copy link
Contributor

/ok to test 78f2877

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
cpp/src/dual_simplex/folding.cpp (2)

252-265: ⚠️ Potential issue | 🟡 Minor

Unsigned subtraction may silently wrap instead of going negative.

Both .size() calls return size_t (unsigned). If vertices_to_refine_by_color[color].size() exceeds colors[color].vertices.size(), the subtraction wraps to a very large unsigned value before assignment to i_t. On most two's-complement systems this converts to negative, but it's implementation-defined and could silently yield a positive i_t on some platforms, bypassing the < 0 guard.

Proposed fix
-  const i_t remaining_size =
-    colors[color].vertices.size() - vertices_to_refine_by_color[color].size();
-  if (remaining_size < 0) {
+  if (vertices_to_refine_by_color[color].size() > colors[color].vertices.size()) {
     printf("Negative remaining size %d\n", remaining_size);
     ...
+    return -1;
+  }
+  const i_t remaining_size =
+    static_cast<i_t>(colors[color].vertices.size() - vertices_to_refine_by_color[color].size());
+  {

The same pattern appears at lines 314-315. As per coding guidelines, validate correct initialization and bounds before operations.


738-741: ⚠️ Potential issue | 🟡 Minor

Log message missing trailing newline.

Line 739 is missing \n, which will concatenate with the next log line. This is the same class of issue that was fixed at line 712.

Proposed fix
-      settings.log.printf("Folding: Number of colors exceeds threshold");
+      settings.log.printf("Folding: Number of colors exceeds threshold\n");
🧹 Nitpick comments (1)
cpp/src/dual_simplex/folding.cpp (1)

404-405: color_lower_bounds still uses std::unordered_map<f_t, ...> with floating-point keys.

The PR specifically switches color_sums and sum_to_sizes from unordered_map to map to avoid floating-point hashing issues, but unique_row_sums and unique_col_sums here use the same problematic pattern. This function is called once (not on the hot path), so it's a consistency/correctness concern rather than performance.

Suggested change for consistency
-  std::unordered_map<f_t, i_t> unique_row_sums;
-  std::unordered_map<f_t, i_t> unique_col_sums;
+  std::map<f_t, i_t> unique_row_sums;
+  std::map<f_t, i_t> unique_col_sums;

@rg20
Copy link
Contributor Author

rg20 commented Feb 6, 2026

/ok to test 3835de5

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@cpp/tests/linear_programming/unit_tests/presolve_test.cu`:
- Around line 381-384: The relative-error computation uses expected_obj as
divisor which can be zero; update the check around where primal_obj,
expected_obj and rel_error are computed (using
solution.get_additional_termination_information()) to guard against
division-by-zero: if expected_obj is effectively zero (abs(expected_obj) <
small_eps) compute an absolute error (abs(primal_obj - expected_obj)) and assert
with EXPECT_LT/EXPECT_NEAR accordingly, otherwise compute the relative error as
before and use EXPECT_LT; keep the existing failure message (<< "Problem " <<
name << " objective mismatch") and apply this branch to the
rel_error/expectation logic.
🧹 Nitpick comments (4)
cpp/tests/linear_programming/pdlp_test.cu (1)

214-238: run_sub_mittleman doesn't exercise the new PSLP presolver.

The loop tests presolver_t::Papilo and presolver_t::None but omits presolver_t::PSLP, which is the primary feature of this PR. While dedicated PSLP tests exist in presolve_test.cu, the Mittelman suite provides broader instance coverage. Consider adding presolver_t::PSLP to the presolver loop here, potentially with a relaxed epsilon similar to Papilo.

cpp/tests/linear_programming/unit_tests/presolve_test.cu (3)

166-199: Weak duality check uses a very loose tolerance.

EXPECT_NEAR(primal_obj, dual_obj, 1.0) allows up to 1.0 absolute gap for an objective around -464. Consider tightening this or using a relative tolerance, and/or verifying a few dual variable values rather than just the vector size.


322-348: Reduced costs test only validates vector size, not values.

This test verifies that the postsolved reduced costs vector has the right dimension but doesn't check any numerical properties (e.g., reduced costs should be non-negative for non-basic variables at their lower bounds in a minimization LP). Even a basic sanity check would strengthen coverage. As per coding guidelines, tests should validate numerical correctness of optimization results, not just "runs without error."


351-386: Consider extending the multi-problem test to cover more instances and edge cases.

Currently only afiro_original and ex10 are tested with PSLP. Consider adding a few more instances from the Mittelman suite (e.g., datt256_lp, savsched1) and at least one degenerate case (empty or trivially infeasible problem) to validate PSLP postsolve robustness. As per coding guidelines, tests should cover degenerate cases such as infeasible, unbounded, empty, and singleton problems.

Comment on lines +381 to +384

double primal_obj = solution.get_additional_termination_information().primal_objective;
double rel_error = std::abs((primal_obj - expected_obj) / expected_obj);
EXPECT_LT(rel_error, 0.01) << "Problem " << name << " objective mismatch";
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential division by zero in relative error computation.

Line 383 computes std::abs((primal_obj - expected_obj) / expected_obj). If a test instance with expected_obj == 0.0 is added (e.g., woodlands09 from the Mittelman suite has objective 0.0), this produces undefined behavior. Guard against this case.

🛡️ Proposed fix
     double primal_obj = solution.get_additional_termination_information().primal_objective;
-    double rel_error  = std::abs((primal_obj - expected_obj) / expected_obj);
-    EXPECT_LT(rel_error, 0.01) << "Problem " << name << " objective mismatch";
+    if (expected_obj != 0.0) {
+      double rel_error = std::abs((primal_obj - expected_obj) / expected_obj);
+      EXPECT_LT(rel_error, 0.01) << "Problem " << name << " objective mismatch";
+    } else {
+      EXPECT_NEAR(primal_obj, 0.0, 1e-4) << "Problem " << name << " objective mismatch";
+    }
🤖 Prompt for AI Agents
In `@cpp/tests/linear_programming/unit_tests/presolve_test.cu` around lines 381 -
384, The relative-error computation uses expected_obj as divisor which can be
zero; update the check around where primal_obj, expected_obj and rel_error are
computed (using solution.get_additional_termination_information()) to guard
against division-by-zero: if expected_obj is effectively zero (abs(expected_obj)
< small_eps) compute an absolute error (abs(primal_obj - expected_obj)) and
assert with EXPECT_LT/EXPECT_NEAR accordingly, otherwise compute the relative
error as before and use EXPECT_LT; keep the existing failure message (<<
"Problem " << name << " objective mismatch") and apply this branch to the
rel_error/expectation logic.

Copy link
Contributor

@chris-maes chris-maes left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks for hooking up the PSLP presolver.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cpp/src/mip/presolve/third_party_presolve.cpp (1)

75-81: ⚠️ Potential issue | 🟠 Major

Missing stream synchronization before accessing host vectors.

The raft::copy calls (lines 55–75) are asynchronous on stream_view, but host vectors are read immediately at line 77. This is the same race condition that was correctly fixed in build_and_run_pslp_presolver (line 238). Add synchronization here for consistency and correctness.

Proposed fix
   raft::copy(h_var_types.data(), var_types.data(), var_types.size(), stream_view);
 
+  stream_view.synchronize();
+
   if (maximize) {
     for (size_t i = 0; i < h_obj_coeffs.size(); ++i) {

As per coding guidelines: "Eliminate unnecessary host-device synchronization (cudaDeviceSynchronize) in hot paths that blocks GPU pipeline; use streams and events for async execution" — but synchronization is required here before host reads of async-copied data.

🧹 Nitpick comments (1)
cpp/src/mip/presolve/third_party_presolve.cpp (1)

213-258: Significant code duplication with build_papilo_problem.

Lines 213–258 (device-to-host copies + constraint bounds conversion) are nearly identical to lines 53–97 in build_papilo_problem. Consider extracting a shared helper (e.g., copy_problem_data_to_host) that returns a struct of host vectors, and call it from both functions.

@rg20
Copy link
Contributor Author

rg20 commented Feb 6, 2026

/ok to test 9fab0d1

@rg20
Copy link
Contributor Author

rg20 commented Feb 7, 2026

/ok to test 805d7bb

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cpp/src/mip/presolve/third_party_presolve.cpp (1)

52-81: ⚠️ Potential issue | 🔴 Critical

Missing stream synchronization before host access in build_papilo_problem.

The raft::copy calls (lines 55–75) are asynchronous on stream_view, but line 77 immediately reads h_obj_coeffs on the host without synchronizing. This is the same race condition that was fixed in build_and_run_pslp_presolver (line 238) but was not applied here. All subsequent host accesses (h_row_types, h_constr_lb, h_bounds, etc.) are also at risk.

Proposed fix
   std::vector<var_t> h_var_types(var_types.size());
   raft::copy(h_var_types.data(), var_types.data(), var_types.size(), stream_view);
 
+  stream_view.synchronize();
+
   if (maximize) {
     for (size_t i = 0; i < h_obj_coeffs.size(); ++i) {

As per coding guidelines: "Eliminate unnecessary host-device synchronization (cudaDeviceSynchronize) in hot paths that blocks GPU pipeline" — the converse also applies: synchronization is required before host reads of async-copied buffers.

🤖 Fix all issues with AI agents
In `@cpp/src/mip/presolve/third_party_presolve.cpp`:
- Around line 339-345: The loop uses a size_t index while n_cols is an int,
risking silent wrap if n_cols is negative; in the block that negates
h_obj_coeffs when maximize is true, change the loop to use a signed index type
consistent with n_cols (e.g., for (int i = 0; i < n_cols; ++i)) or first
validate/convert n_cols to a non-negative size_t (e.g., check n_cols >= 0 then
use size_t nn = static_cast<size_t>(n_cols) and iterate with i < nn) so that
h_obj_coeffs, reduced_prob->c and the maximize branch operate over a safe,
consistently-typed range.

In `@python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py`:
- Around line 92-96: The assertion is wrong because "or
MILPTerminationStatus.Optimal" is always truthy; change the check on
solution.get_termination_status() to explicitly compare against both enum values
(e.g., use "status == MILPTerminationStatus.FeasibleFound or status ==
MILPTerminationStatus.Optimal" or "status in
(MILPTerminationStatus.FeasibleFound, MILPTerminationStatus.Optimal)") so the
test actually fails on unexpected statuses; reference the existing call
solution.get_termination_status() and the enum MILPTerminationStatus when
editing the assertion.
🧹 Nitpick comments (2)
python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py (1)

577-591: No Python-level tests explicitly exercise the new PSLP presolve path.

test_dual_simplex is the only test that implicitly runs PSLP (via the default). All other tests explicitly disable presolve. Consider adding at least one test that:

  1. Explicitly sets CUOPT_PRESOLVE to the PSLP value and validates numerical correctness of the postsolve solution.
  2. Tests a non-trivial LP through the full presolve → solve → postsolve pipeline, verifying the solution against known optimal values.

This would catch regressions in the PSLP integration at the Python API level. As per coding guidelines: "Add tests for problem transformations: verify correctness of original→transformed→postsolve mappings and index consistency across problem representations."

cpp/src/mip/presolve/third_party_presolve.cpp (1)

213-258: Significant code duplication with build_papilo_problem (lines 52–97).

The device-to-host copy block, objective negation for maximize, and constraint bound construction are nearly identical between build_and_run_pslp_presolver and build_papilo_problem. Extracting a shared helper (e.g., copy_problem_data_to_host(...)) would reduce the ~45 duplicated lines to a single call site in each builder, making future bug fixes (like the stream sync) consistent.

Comment on lines +339 to +345
std::vector<f_t> h_obj_coeffs(n_cols);
std::copy(reduced_prob->c, reduced_prob->c + n_cols, h_obj_coeffs.begin());
if (maximize) {
for (size_t i = 0; i < n_cols; ++i) {
h_obj_coeffs[i] = -h_obj_coeffs[i];
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Signed/unsigned comparison: size_t i < n_cols where n_cols is int.

n_cols is declared as int (line 313) but compared against size_t i (line 342). If n_cols were somehow negative, this would silently convert to a large unsigned value and iterate incorrectly. Use a consistent unsigned type or add a guard.

Proposed fix
-  std::vector<f_t> h_obj_coeffs(n_cols);
-  std::copy(reduced_prob->c, reduced_prob->c + n_cols, h_obj_coeffs.begin());
+  std::vector<f_t> h_obj_coeffs(static_cast<size_t>(n_cols));
+  std::copy(reduced_prob->c, reduced_prob->c + n_cols, h_obj_coeffs.begin());
   if (maximize) {
-    for (size_t i = 0; i < n_cols; ++i) {
+    for (int i = 0; i < n_cols; ++i) {
       h_obj_coeffs[i] = -h_obj_coeffs[i];
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
std::vector<f_t> h_obj_coeffs(n_cols);
std::copy(reduced_prob->c, reduced_prob->c + n_cols, h_obj_coeffs.begin());
if (maximize) {
for (size_t i = 0; i < n_cols; ++i) {
h_obj_coeffs[i] = -h_obj_coeffs[i];
}
}
std::vector<f_t> h_obj_coeffs(static_cast<size_t>(n_cols));
std::copy(reduced_prob->c, reduced_prob->c + n_cols, h_obj_coeffs.begin());
if (maximize) {
for (int i = 0; i < n_cols; ++i) {
h_obj_coeffs[i] = -h_obj_coeffs[i];
}
}
🤖 Prompt for AI Agents
In `@cpp/src/mip/presolve/third_party_presolve.cpp` around lines 339 - 345, The
loop uses a size_t index while n_cols is an int, risking silent wrap if n_cols
is negative; in the block that negates h_obj_coeffs when maximize is true,
change the loop to use a signed index type consistent with n_cols (e.g., for
(int i = 0; i < n_cols; ++i)) or first validate/convert n_cols to a non-negative
size_t (e.g., check n_cols >= 0 then use size_t nn = static_cast<size_t>(n_cols)
and iterate with i < nn) so that h_obj_coeffs, reduced_prob->c and the maximize
branch operate over a safe, consistently-typed range.

Comment on lines 92 to 96
assert (
solution.get_termination_status()
== MILPTerminationStatus.FeasibleFound
or MILPTerminationStatus.Optimal
)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: or MILPTerminationStatus.Optimal is always truthy — assertion never fails.

Due to Python operator precedence, this evaluates as:

assert (status == MILPTerminationStatus.FeasibleFound) or MILPTerminationStatus.Optimal

MILPTerminationStatus.Optimal is a truthy enum member, so the entire expression is always truthy regardless of the actual termination status. The test will pass even on INFEASIBLE or NUMERICAL_ISSUES.

🐛 Proposed fix
     assert (
-        solution.get_termination_status()
-        == MILPTerminationStatus.FeasibleFound
-        or MILPTerminationStatus.Optimal
+        solution.get_termination_status()
+        in (MILPTerminationStatus.FeasibleFound, MILPTerminationStatus.Optimal)
     )

As per coding guidelines, tests should validate numerical correctness of optimization results, not just "run without error." This assertion is effectively a no-op.

🤖 Prompt for AI Agents
In `@python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py`
around lines 92 - 96, The assertion is wrong because "or
MILPTerminationStatus.Optimal" is always truthy; change the check on
solution.get_termination_status() to explicitly compare against both enum values
(e.g., use "status == MILPTerminationStatus.FeasibleFound or status ==
MILPTerminationStatus.Optimal" or "status in
(MILPTerminationStatus.FeasibleFound, MILPTerminationStatus.Optimal)") so the
test actually fails on unexpected statuses; reference the existing call
solution.get_termination_status() and the enum MILPTerminationStatus when
editing the assertion.

@rg20
Copy link
Contributor Author

rg20 commented Feb 7, 2026

/ok to test f02c30f

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature request New feature or request non-breaking Introduces a non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants