crossover:v0; first simple version for feedback#56
Conversation
Codecov Report❌ Patch coverage is
... and 2 files with indirect coverage changes 🚀 New features to boost your workflow:
|
b89002c to
af49ead
Compare
gdalle
left a comment
There was a problem hiding this comment.
Thank you for giving this a shot! Of course there are lots of comments, but as you'll learn if you continue to contribute to open-source, this is rather a good sign :) If you ever get a review of a 500-LOC PR with a single comment, the maintainer didn't read your code.
My main doubts are related to the algorithm you picked: is it even called a crossover? Why not go for the full Megiddo approach with simplex pivot steps?
| crossover_threshold = 1.0e-6, | ||
| crossover_fixed_tol = 1.0e-8, | ||
| crossover_rollback_on_kkt_regression = true, | ||
| crossover_kkt_rtol = 0.0, | ||
| crossover_use_effective_bounds = true, |
There was a problem hiding this comment.
Where do these default values come from?
There was a problem hiding this comment.
Pragmatic V1 knobs, not from a single paper constant:
1e-6/1e-8: absolute snap tolerances (same order as PDLP residual scales and cuPDLPx-style "near bound" heuristics).rollback_on_kkt_regression = true,kkt_rtol = 0: conservative default —
keep crossover only if KKT does not worsen.use_effective_bounds = true: enables the equality-row implied-bound pass before snapping.
These are defaults only; the user can override them viaPDLP/PDHGkwargs
| reltol_T = _T(termination_reltol) | ||
| crossover_params = CrossoverParameters(; | ||
| enabled = crossover, | ||
| threshold = max(_T(crossover_threshold), reltol_T / 20), |
There was a problem hiding this comment.
Heuristic floor: effective snap threshold = max(crossover_threshold, termination_reltol / 20).
With a loose reltol (e.g. 1e-3), PDLP can stop as OPTIMAL while some coords are still O(reltol) from bounds; the default 1e-6 snap would barely fire. The floor raises the snap radius to at least reltol/20 (here 5e-5)
so near-bound coords at the solve-tolerance scale actually get snapped.
Factor 20 is arbitrary, i can remove this coupling entirely and just use crossover_threshold as
given (user sets it explicitly if they want a looser snap), or expose it as a named parameter (crossover_reltol_fraction) if you prefer to keep the link.
| end | ||
| (; termination_reltol) = algo.termination | ||
| err_before = stats.err | ||
| x_backup = copy(state.sol.x) |
There was a problem hiding this comment.
We might be able to overwrite some field from the scratch space to avoid allocating? It doesn't matter much though, since we only apply crossover once
There was a problem hiding this comment.
We cannot reuse scratch.x / y / z for the backup:
kkt_errors! overwrites all three when checking post-crossover residuals.
| "revert to the pre-crossover primal if KKT errors regress beyond tolerances" | ||
| rollback_on_kkt_regression::Bool = true |
There was a problem hiding this comment.
Is this boolean parameter necessary? To simulate rollback_on_kkt_regression = false, one could also set kkt_rtol = Inf?
There was a problem hiding this comment.
Not completely equivalent :)
rollback_on_kkt_regression = false skips all rollback checks
(crossover_kkt_acceptable returns true immediately). kkt_rtol = Inf only disables the relative increase test; we still reject if relative(err_after) > termination_reltol. Tests rely on rollback=false accepting a worsened iterate. Keeping the bool for clarity, but it is still possible to remove it and have +inf instead, as you want.
| elseif crossover_applied | ||
| "crossover applied ($crossover_n_snapped coords)" | ||
| else | ||
| "crossover not applied" |
There was a problem hiding this comment.
That else branch is the catch-all message when crossover did not stick:
disabled (crossover=false), enabled but nothing snapped (n_changed == 0),
or we never kept a post-crossover primal. show only has three strings total
— rolled back / applied / not applied — and this is the third.
| push!(free, j) | ||
| end | ||
| end | ||
| length(free) == 1 || continue |
There was a problem hiding this comment.
How often do we have length(free) = 1 on realistic cases, e.g. from MathOptBenchmarkInstances.jl?
There was a problem hiding this comment.
The pass only tightens when an equality has exactly one free (not-at-box) variable — if that is rare on realistic LPs, the feature buys little.
Local tests (unit + toy LPs) show that case does occur and enables snaps
when PDLP already puts vars on bounds; on larger/random MOBI-style
instances it is often a no-op, and cheap (continue).
| end | ||
|
|
||
| function crossover_n_changed(x_after, x_before) | ||
| return sum(x_after .!= x_before) |
There was a problem hiding this comment.
This version is non-allocating:
count(!=, x_after, x_before)There was a problem hiding this comment.
count(!=, a, b) is not defined for two AbstractVectors
on Julia 1.10–1.12 (MethodError). CPU path: explicit loop (non-allocating);
GPU path: sum(x_after .!= x_before) (broadcast).
| if at_box_cpu[j] | ||
| slack -= aij * x_cpu[j] | ||
| else | ||
| push!(free, j) |
There was a problem hiding this comment.
Judging by what comes below, this loop might be interrupted early as soon as we find two free variables. Thus, the free vector might not even be necessary?
There was a problem hiding this comment.
INdeed - replaced free::Vector{Int} with (n_free, free_j, free_aij) counters
and break when n_free > 1.
| if !isfinite(uv_cpu[j]) | ||
| uv_cpu[j] = implied | ||
| else | ||
| uv_cpu[j] = min(uv_cpu[j], implied) | ||
| end |
There was a problem hiding this comment.
The else actually works for both cases
There was a problem hiding this comment.
Merci - simplified to uv_cpu[j] = min(uv_cpu[j], slack/aij) and
lv_cpu[j] = max(lv_cpu[j], slack/aij) (min/max with ±Inf handle both
cases).
| end | ||
|
|
||
| """ | ||
| fraction_at_bounds(x, milp; atol=1e-12) |
There was a problem hiding this comment.
Needed for the apply/rollback gate (n_changed > 0), not only display. The
exact integer is also stored as stats.crossover_n_snapped and shown in
ConvergenceStats — useful for tests/debug, optional for end users. Happy to
keep only the bools (applied / rolled_back) and drop the count from the
public stats if you prefer a thinner API.
After OPTIMAL termination, optionally snap primal coordinates near finite box bounds, tighten implied bounds from equality rows, and roll back if KKT errors regress. Controlled by CrossoverParameters on Algorithm (on by default). Effective bounds run on CPU once per solve and copy back to device. Not a basic-vertex crossover (unlike cuOpt): useful when PDLP stops near bounds, usually a no-op for strictly interior solutions. Refs JuliaDecisionFocusedLearning#13
Enum values are not separate doc bindings; use inline code instead of @ref.
…er fixes. Document V1 vs Megiddo, parametrize bound/eq atol, traverse At via nzrange, drop @inbounds/free vector, simplify implied bounds, and cover ConvergenceStats show.
Mutate effective bounds in place after the caller materializes CPU vectors, clarify the implied-bounds docstring, and remove review-only comments.
|
Thank you for your review and all the comments that were very instructive. |
Add V1 post-solve crossover (threshold snap + implied bounds) Refs #13
Post-OPTIMAL crossover for PDLP: snap primal coordinates near finite box bounds, tighten implied bounds from equality rows, rollback if KKT residuals regress. Controlled by CrossoverParameters on Algorithm (enabled by default).
V1 is not a basic-vertex crossover (unlike cuOpt): it helps near-bound / implied-bound cases (e.g. toy_lp) but is usually a no-op when variables stay strictly interior. Lightweight first step toward MIP-ready solutions.
Also realized a small bench compared to cuopt:

I am planning a more advanced version cuopt-like after feedbacks