Skip to content

Commit af49ead

Browse files
author
Charles Vielzeuf
committed
Add post-solve crossover V1 (threshold snap + implied bounds).
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 #13
1 parent 9297e0b commit af49ead

5 files changed

Lines changed: 519 additions & 7 deletions

File tree

src/CoolPDLP.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ include("public.jl")
4242
include("components/restart.jl")
4343
include("components/generic.jl")
4444
include("components/termination.jl")
45+
include("components/crossover.jl")
4546

4647
include("algorithms/common.jl")
4748
include("algorithms/pdhg.jl")

src/algorithms/common.jl

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ struct Algorithm{
1818
restart::RestartParameters{T}
1919
generic::GenericParameters
2020
termination::TerminationParameters{T}
21+
crossover::CrossoverParameters{T}
2122
end
2223

2324
"""
@@ -46,6 +47,13 @@ end
4647
termination_reltol = 1.0e-4,
4748
max_kkt_passes = 10^5,
4849
time_limit = 100.0,
50+
# crossover
51+
crossover = true,
52+
crossover_threshold = 1.0e-6,
53+
crossover_fixed_tol = 1.0e-8,
54+
crossover_rollback_on_kkt_regression = true,
55+
crossover_kkt_rtol = 0.0,
56+
crossover_use_effective_bounds = true,
4957
)
5058
5159
Constructor for algorithm configs.
@@ -75,6 +83,13 @@ function Algorithm{A}(
7583
termination_reltol = 1.0e-4,
7684
max_kkt_passes = 10^5,
7785
time_limit = 100.0,
86+
# crossover
87+
crossover = true,
88+
crossover_threshold = 1.0e-6,
89+
crossover_fixed_tol = 1.0e-8,
90+
crossover_rollback_on_kkt_regression = true,
91+
crossover_kkt_rtol = 0.0,
92+
crossover_use_effective_bounds = true,
7893
) where {A, T, Ti, M, B}
7994

8095
conversion = ConversionParameters(
@@ -104,19 +119,29 @@ function Algorithm{A}(
104119
max_kkt_passes,
105120
time_limit
106121
)
122+
reltol_T = _T(termination_reltol)
123+
crossover_params = CrossoverParameters(;
124+
enabled = crossover,
125+
threshold = max(_T(crossover_threshold), reltol_T / 20),
126+
fixed_tol = _T(crossover_fixed_tol),
127+
rollback_on_kkt_regression = crossover_rollback_on_kkt_regression,
128+
kkt_rtol = _T(crossover_kkt_rtol),
129+
use_effective_bounds = crossover_use_effective_bounds,
130+
)
107131

108132
return Algorithm{A, T, Ti, M, B}(
109133
conversion,
110134
preconditioning,
111135
step_size,
112136
restart,
113137
generic,
114-
termination
138+
termination,
139+
crossover_params
115140
)
116141
end
117142

118143
function Base.show(io::IO, algo::Algorithm{A}) where {A}
119-
(; conversion, preconditioning, step_size, restart, generic, termination) = algo
144+
(; conversion, preconditioning, step_size, restart, generic, termination, crossover) = algo
120145
return print(
121146
io, """
122147
$A algorithm:
@@ -125,12 +150,55 @@ function Base.show(io::IO, algo::Algorithm{A}) where {A}
125150
- $step_size
126151
- $restart
127152
- $generic
128-
- $termination"""
153+
- $termination
154+
- $crossover"""
129155
)
130156
end
131157

132158
abstract type AbstractState{T, V} end
133159

160+
"""
161+
apply_crossover!(state, milp, algo)
162+
163+
Apply [`crossover_threshold!`](@ref) when `algo.crossover.enabled` and termination is [`OPTIMAL`](@ref).
164+
165+
Roll back to the pre-crossover primal when [`crossover_kkt_acceptable`](@ref) fails.
166+
Updates `state.stats.crossover_applied`, `crossover_rolled_back`, and `crossover_n_snapped`.
167+
"""
168+
function apply_crossover!(
169+
state::AbstractState,
170+
milp::MILP,
171+
algo::Algorithm,
172+
)
173+
params = algo.crossover
174+
stats = state.stats
175+
if !params.enabled
176+
stats.crossover_applied = false
177+
stats.crossover_rolled_back = false
178+
stats.crossover_n_snapped = 0
179+
return nothing
180+
end
181+
(; termination_reltol) = algo.termination
182+
err_before = stats.err
183+
x_backup = copy(state.sol.x)
184+
crossover_threshold!(state.sol, milp, params)
185+
n_changed = crossover_n_changed(state.sol.x, x_backup)
186+
err_after = kkt_errors!(state.scratch, state.sol, milp)
187+
if n_changed > 0 &&
188+
crossover_kkt_acceptable(err_before, err_after, termination_reltol, params)
189+
stats.err = err_after
190+
stats.crossover_applied = true
191+
stats.crossover_rolled_back = false
192+
stats.crossover_n_snapped = n_changed
193+
else
194+
state.sol.x .= x_backup
195+
stats.crossover_applied = false
196+
stats.crossover_rolled_back = n_changed > 0
197+
stats.crossover_n_snapped = 0
198+
end
199+
return nothing
200+
end
201+
134202
function prog_showvalues(state::AbstractState)
135203
err = state.stats.err
136204
(; primal, primal_scale, dual, dual_scale, gap, gap_scale) = err
@@ -198,6 +266,9 @@ function solve(
198266
return get_solution(state, milp), state.stats
199267
end
200268
solve!(state, milp, algo)
269+
if state.stats.termination_status == OPTIMAL
270+
apply_crossover!(state, milp, algo)
271+
end
201272
return get_solution(state, milp), state.stats
202273
end
203274

src/components/crossover.jl

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""
2+
CrossoverParameters
3+
4+
Post-solve crossover settings: threshold snapping to bounds after [`OPTIMAL`](@ref) termination.
5+
6+
See [`crossover_threshold!`](@ref) and [`apply_crossover!`](@ref).
7+
8+
# Fields
9+
10+
$(TYPEDFIELDS)
11+
"""
12+
@kwdef struct CrossoverParameters{T <: Number}
13+
"whether to apply crossover after PDLP/PDHG terminates optimally"
14+
enabled::Bool = true
15+
"distance to a bound below which the primal is snapped to that bound"
16+
threshold::T = 1.0e-6
17+
"tolerance for treating lower and upper bounds as equal (fixed variable)"
18+
fixed_tol::T = 1.0e-8
19+
"revert to the pre-crossover primal if KKT errors regress beyond tolerances"
20+
rollback_on_kkt_regression::Bool = true
21+
"relative KKT increase tolerated above the pre-crossover value (0 = no increase)"
22+
kkt_rtol::T = 0.0
23+
"tighten infinite bounds from equality rows before snapping"
24+
use_effective_bounds::Bool = true
25+
end
26+
27+
function Base.show(io::IO, params::CrossoverParameters)
28+
(; enabled, threshold, fixed_tol, rollback_on_kkt_regression, kkt_rtol, use_effective_bounds) = params
29+
return print(
30+
io,
31+
"CrossoverParameters: enabled=$enabled, threshold=$threshold, fixed_tol=$fixed_tol, ",
32+
"rollback_on_kkt_regression=$rollback_on_kkt_regression, kkt_rtol=$kkt_rtol, ",
33+
"use_effective_bounds=$use_effective_bounds",
34+
)
35+
end
36+
37+
"""
38+
crossover_kkt_acceptable(err_before, err_after, termination_reltol, params)
39+
40+
Return `true` if the post-crossover KKT errors should be kept.
41+
42+
When `rollback_on_kkt_regression` is true, reject the crossover if either:
43+
- `relative(err_after) > termination_reltol`, or
44+
- `relative(err_after) > relative(err_before) * (1 + kkt_rtol)`.
45+
"""
46+
function crossover_kkt_acceptable(
47+
err_before::KKTErrors,
48+
err_after::KKTErrors,
49+
termination_reltol,
50+
params::CrossoverParameters,
51+
)
52+
params.rollback_on_kkt_regression || return true
53+
rel_before = relative(err_before)
54+
rel_after = relative(err_after)
55+
rel_after <= termination_reltol || return false
56+
rel_after <= rel_before * (1 + params.kkt_rtol) || return false
57+
return true
58+
end
59+
60+
function _crossover_at_box_mask(
61+
x::AbstractVector,
62+
lv::AbstractVector,
63+
uv::AbstractVector;
64+
atol::Real = 1.0e-12,
65+
)
66+
at_l = isfinite.(lv) .& (abs.(x .- lv) .<= atol)
67+
at_u = isfinite.(uv) .& (abs.(x .- uv) .<= atol)
68+
return at_l .| at_u
69+
end
70+
71+
function _crossover_cpu_milp(milp::MILP)
72+
milp_csc = set_matrix_type(SparseMatrixCSC, milp)
73+
return adapt(CPU(), milp_csc)
74+
end
75+
76+
"""
77+
crossover_effective_bounds(milp, x)
78+
79+
Box bounds tightened with implied limits from equality rows.
80+
81+
When an equality row has exactly one variable not yet on a finite box bound, that
82+
row's implied bound is used to fill in an infinite bound (e.g. `x₁ ≤ 1` from
83+
`x₁ + x₂ = 1` when `x₂` is already on its lower bound).
84+
85+
Computed on CPU and copied back to the device of `milp.lv` / `milp.uv`.
86+
"""
87+
function crossover_effective_bounds(
88+
milp::MILP{T},
89+
x::AbstractVector{T};
90+
bound_atol::Real = 1.0e-12,
91+
eq_atol::Real = 1.0e-12,
92+
) where {T}
93+
lv_eff = copy(milp.lv)
94+
uv_eff = copy(milp.uv)
95+
milp_cpu = _crossover_cpu_milp(milp)
96+
x_cpu = Vector(x)
97+
lv_cpu = Vector(lv_eff)
98+
uv_cpu = Vector(uv_eff)
99+
at_box = Vector(
100+
_crossover_at_box_mask(x_cpu, Vector(milp_cpu.lv), Vector(milp_cpu.uv); atol = bound_atol),
101+
)
102+
_crossover_effective_bounds!(
103+
lv_cpu,
104+
uv_cpu,
105+
milp_cpu.A,
106+
Vector(milp_cpu.lc),
107+
Vector(milp_cpu.uc),
108+
x_cpu,
109+
at_box;
110+
eq_atol,
111+
)
112+
backend = get_backend(lv_eff)
113+
copyto!(lv_eff, adapt(backend, lv_cpu))
114+
copyto!(uv_eff, adapt(backend, uv_cpu))
115+
return lv_eff, uv_eff
116+
end
117+
118+
function _crossover_effective_bounds!(
119+
lv_eff,
120+
uv_eff,
121+
A::SparseMatrixCSC{T},
122+
lc,
123+
uc,
124+
x,
125+
at_box;
126+
eq_atol::Real = 1.0e-12,
127+
) where {T}
128+
m, n = size(A)
129+
lc_cpu = Vector(lc)
130+
uc_cpu = Vector(uc)
131+
x_cpu = Vector(x)
132+
lv_cpu = Vector(lv_eff)
133+
uv_cpu = Vector(uv_eff)
134+
at_box_cpu = Vector(at_box)
135+
for i in 1:m
136+
isapprox(lc_cpu[i], uc_cpu[i]; atol = eq_atol) || continue
137+
free = Int[]
138+
slack = lc_cpu[i]
139+
@inbounds for j in 1:n
140+
aij = A[i, j]
141+
aij == 0 && continue
142+
if at_box_cpu[j]
143+
slack -= aij * x_cpu[j]
144+
else
145+
push!(free, j)
146+
end
147+
end
148+
length(free) == 1 || continue
149+
j = only(free)
150+
aij = A[i, j]
151+
if aij > 0
152+
implied = slack / aij
153+
if !isfinite(uv_cpu[j])
154+
uv_cpu[j] = implied
155+
else
156+
uv_cpu[j] = min(uv_cpu[j], implied)
157+
end
158+
elseif aij < 0
159+
implied = slack / aij
160+
if !isfinite(lv_cpu[j])
161+
lv_cpu[j] = implied
162+
else
163+
lv_cpu[j] = max(lv_cpu[j], implied)
164+
end
165+
end
166+
end
167+
lv_eff .= lv_cpu
168+
uv_eff .= uv_cpu
169+
return lv_eff, uv_eff
170+
end
171+
172+
"""
173+
crossover_threshold!(x, lv, uv, params::CrossoverParameters)
174+
175+
Snap primal `x` to variable bounds using a fixed threshold.
176+
177+
For each coordinate: fixed variables are set to their bound; otherwise, if `x` is
178+
within `threshold` of a finite lower or upper bound, it is moved to that bound.
179+
"""
180+
function crossover_threshold!(
181+
x::AbstractVector{T},
182+
lv::AbstractVector{T},
183+
uv::AbstractVector{T},
184+
params::CrossoverParameters{T},
185+
) where {T}
186+
(; threshold, fixed_tol) = params
187+
fixed = abs.(lv .- uv) .<= fixed_tol
188+
@. x = ifelse(fixed, lv, x)
189+
near_l = isfinite.(lv) .& (x .- lv .<= threshold)
190+
@. x = ifelse(near_l, lv, x)
191+
near_u = isfinite.(uv) .& (uv .- x .<= threshold)
192+
@. x = ifelse(near_u, uv, x)
193+
return x
194+
end
195+
196+
function crossover_threshold!(
197+
x::AbstractVector{T},
198+
milp::MILP{T},
199+
params::CrossoverParameters{T},
200+
) where {T}
201+
if params.use_effective_bounds
202+
lv_eff, uv_eff = crossover_effective_bounds(milp, x)
203+
else
204+
lv_eff, uv_eff = milp.lv, milp.uv
205+
end
206+
crossover_threshold!(x, lv_eff, uv_eff, params)
207+
return x
208+
end
209+
210+
function crossover_threshold!(
211+
sol::PrimalDualSolution{T},
212+
milp::MILP{T},
213+
params::CrossoverParameters{T},
214+
) where {T}
215+
crossover_threshold!(sol.x, milp, params)
216+
return sol
217+
end
218+
219+
function crossover_n_changed(x_after, x_before)
220+
return sum(x_after .!= x_before)
221+
end
222+
223+
"""
224+
fraction_at_bounds(x, milp; atol=1e-12)
225+
226+
Fraction of coordinates equal to a finite bound.
227+
"""
228+
function fraction_at_bounds(
229+
x::AbstractVector,
230+
milp::MILP;
231+
atol::Real = 1.0e-12,
232+
)
233+
(; lv, uv) = milp
234+
n = length(x)
235+
n == 0 && return 0.0
236+
at_l = isfinite.(lv) .& (abs.(x .- lv) .<= atol)
237+
at_u = isfinite.(uv) .& (abs.(x .- uv) .<= atol)
238+
return sum(at_l .| at_u) / n
239+
end

0 commit comments

Comments
 (0)