NVIDIA / NVIDIA/cuopt

[BUG] PaPILO tolerance is hardcoded

Open
#1,502 4 comments 0 reactions 1 assignee View on GitHub

@hlinsen is already working on this.

Since Jul 6, 2026.

bug
Dominant language
Cuda
Stars
1k
Forks
233
Avg merge
4d 4h
Merged PRs (30d)
95

Description

Describe the bug
The PaPILO tolerance is hard coded in the code in this line:
https://github.com/NVIDIA/cuopt/blame/main/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp#L605-L598C49
Therefore when a tolerance is set by using set_parameter("absolute_primal_tolerance", ...) it get overwritten by that line to 1e-5
presolver.getPresolveOptions().feastol = 1e-5;

Steps/Code to reproduce bug
When using presolve=1 (PaPILO) the postsolve step returns solutions that violate constraints at the 1e-5 level regardless of the user-specified tolerance. The root cause is visible directly in the source: set_presolve_options() in cpp/src/mip_heuristics/presolve/third_party_presolve.cpp (line 598) receives absolute_tolerance as a parameter but never uses it, feastol is assigned a hardcoded constant:

// third_party_presolve.cpp, line 588–598
template <typename i_t, typename f_t>
void set_presolve_options(papilo::Presolve<f_t>& presolver,
                          problem_category_t category,
                          f_t absolute_tolerance,   // ← received
                          f_t relative_tolerance,   // ← received
                          f_t time_limit,
                          bool dual_postsolve,
                          i_t num_cpu_threads)
{
  presolver.getPresolveOptions().tlim    = time_limit;
  presolver.getPresolveOptions().threads = num_cpu_threads;
  presolver.getPresolveOptions().feastol = 1e-5;  // ← hardcoded; absolute_tolerance never used

This can be observed at runtime with a self-contained synthetic LP:

import numpy as np
import scipy.sparse as sp
from cuopt.linear_programming import DataModel, Solve, SolverSettings

rng = np.random.default_rng(42)

# Sparse LP with wide coefficient range — triggers the feastol issue
n_vars, n_rows = 8000, 4000
density = 0.002
nnz = int(n_vars * n_rows * density)

rows = rng.integers(0, n_rows, nnz)
cols = rng.integers(0, n_vars, nnz)

# Mix of large and small coefficients (range 1e-6 to 1e4)
vals = np.where(rng.random(nnz) < 0.05,
                rng.uniform(1e-6, 1e-4, nnz),   # small coefficients
                rng.uniform(1.0,  1e4,  nnz))    # normal coefficients

A = sp.csr_matrix((vals, (rows, cols)), shape=(n_rows, n_vars))

# Feasible RHS: b = A * x_feasible where x_feasible = 0.5 * ones
x_feasible = np.full(n_vars, 0.5)
b = A @ x_feasible

dm = DataModel()
dm.set_csr_constraint_matrix(A.data.astype(np.float64),
                              A.indices.astype(np.int32),
                              A.indptr.astype(np.int32))
dm.set_constraint_lower_bounds(b.astype(np.float64))   # equality: lb = ub = b
dm.set_constraint_upper_bounds(b.astype(np.float64))
dm.set_variable_lower_bounds(np.zeros(n_vars))
dm.set_variable_upper_bounds(np.ones(n_vars))
dm.set_objective_coefficients(rng.random(n_vars))

settings = SolverSettings()
settings.set_parameter("presolve", 1)                      # PaPILO
settings.set_parameter("absolute_primal_tolerance", 1e-8)  # user requests tight tolerance
settings.set_parameter("absolute_dual_tolerance",   1e-8)
settings.set_parameter("time_limit", 60.0)

sol = Solve(dm, settings)
x = np.asarray(sol.get_primal_solution(), dtype=np.float64)

residuals = np.abs(A @ x - b)
print(f"Max constraint violation : {residuals.max():.2e}")   # expect ≤ 1e-8, actual ~1e-5
print(f"Rows violated > 1e-6    : {(residuals > 1e-6).sum()} / {n_rows}")
print(f"Rows violated > 1e-8    : {(residuals > 1e-8).sum()} / {n_rows}")

Expected behavior
The postsolve-reconstructed solution should satisfy all constraints to within the user-specified absolute_primal_tolerance. When the user sets absolute_primal_tolerance=1e-8, constraint violations in the returned solution should be ≤ 1e-8. Instead, violations are bounded only by PaPILO's hardcoded feastol=1e-5, which is never updated from the user-facing tolerance API. The parameters absolute_tolerance and relative_tolerance passed into set_presolve_options() are silently ignored.

A suggested fix would be to propagate the user tolerance into feastol:

presolver.getPresolveOptions().feastol = std::max(absolute_tolerance, 1e-7);
with a floor (e.g. 1e-7) to avoid reintroducing the false-infeasibility issue that the previous tightening from 9e-7 → 1e-5 was meant to address.

Environment details (please complete the following information):
Environment location: Bare-metal (Linux 6.5.0, NVIDIA RTX A4500 20 GB, CUDA 12.9)
Method of cuOpt install: pip (pip install cuopt-cu12==26.6.0)

Additional context
As a workaround, setting per_constraint_residual=1 reduces postsolve violation count by ~44% and max violation by ~100× by giving PaPILO a better-converged PDLP iterate to reconstruct from — but does not eliminate the floor set by feastol.
cuOpt performs no verification step after postsolve: even when PaPILO's own internal check reports kFailed, the solver returns status Optimal with no user-facing warning (unless log_to_console=True).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.