Skip to content

Schur design constraints and sparse Schur complement solver - #623

Open
michel2323 wants to merge 31 commits into
masterfrom
schur-design-constraints
Open

Schur design constraints and sparse Schur complement solver#623
michel2323 wants to merge 31 commits into
masterfrom
schur-design-constraints

Conversation

@michel2323

@michel2323 michel2323 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

This is another pass over the Schur complement-based solver while adding design constraint support and replacing the dense Schur complement solve with sparse CUDSS on the GPU.

I'm sorry that it is again a large diff. But the tag-based detection of cross and design only constraints and the change in the structure implied a new variable ordering.

I've added our classic SCOPF formulation that we used in ECP and elsewhere to ExaModelsPower.jl (no PR yet). This was very useful to debug. Currently, it passes all IEEE case 9 and 118 with the line contingencies we always used.

One major change was also that I did not relax the equality constraints in the GPU solver. This is fixed now.

@michel2323
michel2323 requested a review from frapac June 8, 2026 15:11
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Your PR requires formatting changes to meet the project's style guidelines.
Please consider running Runic (git runic master) to apply these changes.

Click here to view the suggested changes.
diff --git a/lib/MadNLPGPU/ext/MadNLPGPUAMDGPUExt/rocm.jl b/lib/MadNLPGPU/ext/MadNLPGPUAMDGPUExt/rocm.jl
index 3375891..ecd2ea8 100644
--- a/lib/MadNLPGPU/ext/MadNLPGPUAMDGPUExt/rocm.jl
+++ b/lib/MadNLPGPU/ext/MadNLPGPUAMDGPUExt/rocm.jl
@@ -9,7 +9,7 @@ function MadNLP.MadNLPOptions{T}(
     kkt_system = dense_callback ? MadNLP.DenseCondensedKKTSystem : MadNLP.SparseCondensedKKTSystem,
     linear_solver = MadNLPGPU.LapackROCmSolver,
     tol = MadNLP.get_tolerance(T,kkt_system),
-    bound_relax_factor = (kkt_system <: MadNLP.SparseCondensedKKTSystem) ? tol : T(1.0e-8),
+        bound_relax_factor = (kkt_system <: MadNLP.SparseCondensedKKTSystem) ? tol : T(1.0e-8),
 ) where {T, VT <: ROCVector{T}}
     return MadNLP.MadNLPOptions{T}(
         tol = tol,
@@ -28,9 +28,9 @@ function MadNLP.create_kkt_system(
     ) where {T, VT <: ROCVector{T}}
     return error(
         "SchurComplementCondensedKKTSystem is not supported on ROCm/AMDGPU: there is no ROCm " *
-        "Schur implementation (the batched per-scenario factorization and Schur reduction are " *
-        "cuDSS / CUBLAS-specific). Use a CUDA device for the GPU Schur path, or solve the Schur " *
-        "system on CPU."
+            "Schur implementation (the batched per-scenario factorization and Schur reduction are " *
+            "cuDSS / CUBLAS-specific). Use a CUDA device for the GPU Schur path, or solve the Schur " *
+            "system on CPU."
     )
 end
 
diff --git a/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/cuda_schur.jl b/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/cuda_schur.jl
index 75a06b4..9c1c364 100644
--- a/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/cuda_schur.jl
+++ b/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/cuda_schur.jl
@@ -250,14 +250,18 @@ function _compute_det_scatter(kkt::GPUSchurComplementCondensedKKTSystem)
     n_iA = kkt.n_per_s_ineq_Akk
     nz = Array(kkt.gpu_ineq_Akk_nzpos)
     slotA = n_iA > 0 ? Int[((i - 1) ÷ n_iA) * nnzb + nz[i] for i in eachindex(nz)] : Int[]
-    Akk = _segment_scatter(slotA,
-        Array(kkt.gpu_ineq_Akk_jcoo1), Array(kkt.gpu_ineq_Akk_jcoo2), Array(kkt.gpu_ineq_Akk_bufidx))
+    Akk = _segment_scatter(
+        slotA,
+        Array(kkt.gpu_ineq_Akk_jcoo1), Array(kkt.gpu_ineq_Akk_jcoo2), Array(kkt.gpu_ineq_Akk_bufidx)
+    )
     # ineq → C_dk: linear slot into (blk × m × ns) = v + (d-1)*blk + (k-1)*blk*m
     n_iC = kkt.n_per_s_ineq_Cdk
     vv = Array(kkt.gpu_ineq_Cdk_col); dd = Array(kkt.gpu_ineq_Cdk_row)
     slotC = n_iC > 0 ? Int[vv[i] + (dd[i] - 1) * blk + ((i - 1) ÷ n_iC) * blk * m for i in eachindex(vv)] : Int[]
-    Cdk = _segment_scatter(slotC,
-        Array(kkt.gpu_ineq_Cdk_jcoo_d), Array(kkt.gpu_ineq_Cdk_jcoo_v), Array(kkt.gpu_ineq_Cdk_bufidx))
+    Cdk = _segment_scatter(
+        slotC,
+        Array(kkt.gpu_ineq_Cdk_jcoo_d), Array(kkt.gpu_ineq_Cdk_jcoo_v), Array(kkt.gpu_ineq_Cdk_bufidx)
+    )
     # ineq → S: merge scenario + design-only contributions, slot = nzpos
     slotS = vcat(Array(kkt.schur_ineq_S_nzpos), Array(kkt.schur_design_ineq_S_nzpos))
     j1S = vcat(Array(kkt.schur_ineq_S_jcoo1), Array(kkt.schur_design_ineq_S_jcoo1))
@@ -305,9 +309,11 @@ function _compute_det_scatter(kkt::GPUSchurComplementCondensedKKTSystem)
     njac = length(kkt.jac)
     jt_lin = _segment_lin_scatter(Array(kkt.jt_csc_map), collect(1:njac))
 
-    return (Akk = Akk, Cdk = Cdk, S = S, hessAkk = hessAkk, hessCdk = hessCdk, hessS = hessS,
-            hess_lin = hess_lin, hess_full = hess_full, hess_full_perm = hess_full_perm,
-            jt_lin = jt_lin)
+    return (
+        Akk = Akk, Cdk = Cdk, S = S, hessAkk = hessAkk, hessCdk = hessCdk, hessS = hessS,
+        hess_lin = hess_lin, hess_full = hess_full, hess_full_perm = hess_full_perm,
+        jt_lin = jt_lin,
+    )
 end
 
 function _get_det_scatter(kkt::GPUSchurComplementCondensedKKTSystem)
@@ -317,7 +323,7 @@ end
 
 # --- Dispatch: GPU path when callback uses CuVector ---
 function MadNLP.create_kkt_system(
-    ::Type{MadNLP.SchurComplementCondensedKKTSystem},
+        ::Type{MadNLP.SchurComplementCondensedKKTSystem},
     cb::MadNLP.SparseCallback{T, VT},
     linear_solver::Type;
     opt_linear_solver=MadNLP.default_options(linear_solver),
@@ -479,7 +485,7 @@ function MadNLP.create_kkt_system(
     gpu_hess_Cdk_row = CuVector{Int}(coupled_inv[flat.all_hess_Cdk_row])
     gpu_hess_Cdk_col    = CuVector{Int}(flat.all_hess_Cdk_col)
     gpu_pr_diag_global  = CuVector{Int}(flat.all_pr_diag_global)
-    gpu_pr_diag_nzpos   = CuVector{Int}(flat.all_pr_diag_nzpos)
+    gpu_pr_diag_nzpos = CuVector{Int}(flat.all_pr_diag_nzpos)
     gpu_ineq_Akk_nzpos  = CuVector{Int}(flat.all_ineq_Akk_nzpos)
     gpu_ineq_Akk_jcoo1  = CuVector{Int}(flat.all_ineq_Akk_jcoo1)
     gpu_ineq_Akk_jcoo2  = CuVector{Int}(flat.all_ineq_Akk_jcoo2)
@@ -620,7 +626,7 @@ end
 # iteration. Passing `det` to a specialized inner method makes every `det.X` access (and the
 # kernel launches taking them) type-stable after a single dynamic dispatch at the call.
 MadNLP.build_kkt!(kkt::GPUSchurComplementCondensedKKTSystem) = _schur_build_kkt!(kkt, _get_det_scatter(kkt))
-function _schur_build_kkt!(kkt::GPUSchurComplementCondensedKKTSystem{T}, det) where T
+function _schur_build_kkt!(kkt::GPUSchurComplementCondensedKKTSystem{T}, det) where {T}
     ns = kkt.ns
     nv = kkt.nv
     nd = kkt.nd
@@ -767,7 +773,7 @@ end
 
 # --- solve_kkt! ---
 function MadNLP.solve_kkt!(
-    kkt::GPUSchurComplementCondensedKKTSystem{T},
+        kkt::GPUSchurComplementCondensedKKTSystem{T},
     w::MadNLP.AbstractKKTVector{T},
 ) where T
 
@@ -866,20 +872,20 @@ end
 # hit many times per Richardson step) out of the hot body so the SpMV dispatches statically.
 function MadNLP.mul!(
     w::MadNLP.AbstractKKTVector{T, VT},
-    kkt::GPUSchurComplementCondensedKKTSystem{T},
+        kkt::GPUSchurComplementCondensedKKTSystem{T},
     x::MadNLP.AbstractKKTVector,
     alpha = one(T),
     beta = zero(T),
-) where {T, VT <: CuVector{T}}
+    ) where {T, VT <: CuVector{T}}
     return _schur_mul!(w, kkt, x, alpha, beta, _get_det_scatter(kkt).hess_full)
 end
 function _schur_mul!(
-    w::MadNLP.AbstractKKTVector{T, VT},
-    kkt::GPUSchurComplementCondensedKKTSystem{T},
-    x::MadNLP.AbstractKKTVector,
-    alpha,
-    beta,
-    hess_full,
+        w::MadNLP.AbstractKKTVector{T, VT},
+        kkt::GPUSchurComplementCondensedKKTSystem{T},
+        x::MadNLP.AbstractKKTVector,
+        alpha,
+        beta,
+        hess_full,
 ) where {T, VT <: CuVector{T}}
     n = MadNLP.num_variables(kkt)
 
diff --git a/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/cudss.jl b/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/cudss.jl
index b2810dc..247f972 100644
--- a/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/cudss.jl
+++ b/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/cudss.jl
@@ -4,7 +4,7 @@ MadNLP.@kwdef mutable struct CudssSolverOptions <: MadNLP.AbstractOptions
     cudss_ordering::ORDERING = DEFAULT_ORDERING
     cudss_perm::Vector{Cint} = Cint[]
     cudss_ir::Int = 0
-    cudss_ir_tol::Float64 = 1e-8
+    cudss_ir_tol::Float64 = 1.0e-8
     cudss_pivot_threshold::Float64 = 0.0
     cudss_pivot_epsilon::Float64 = 0.0
     cudss_matching_alg::String = "default"
diff --git a/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/kernels_schur.jl b/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/kernels_schur.jl
index 85d5651..a5578b0 100644
--- a/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/kernels_schur.jl
+++ b/lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/kernels_schur.jl
@@ -30,11 +30,11 @@ end
 # `scen_var_global` (flat, length ns*nv, scenario-major), since a scenario's
 # variables need not be contiguous.
 @kernel function _extract_rhs_kernel!(
-    rhs_k,
-    @Const(wx),
-    @Const(scen_var_global),
-    @Const(nv),
-    @Const(ns),
+        rhs_k,
+        @Const(wx),
+        @Const(scen_var_global),
+        @Const(nv),
+        @Const(ns),
 )
     i = @index(Global)
     k = (i - 1) ÷ nv + 1
@@ -47,11 +47,11 @@ end
 
 # Write back per-scenario solution to global wx
 @kernel function _writeback_rhs_kernel!(
-    wx,
-    @Const(rhs_k),
-    @Const(scen_var_global),
-    @Const(nv),
-    @Const(ns),
+        wx,
+        @Const(rhs_k),
+        @Const(scen_var_global),
+        @Const(nv),
+        @Const(ns),
 )
     i = @index(Global)
     k = (i - 1) ÷ nv + 1
diff --git a/src/IPM/options.jl b/src/IPM/options.jl
index fddd006..8494544 100644
--- a/src/IPM/options.jl
+++ b/src/IPM/options.jl
@@ -214,14 +214,14 @@ function MadNLPOptions{T}(
     callback = dense_callback ? DenseCallback : SparseCallback,
     kkt_system = dense_callback ? DenseCondensedKKTSystem : SparseKKTSystem,
     linear_solver = dense_callback ? LapackCPUSolver : default_sparse_solver(nlp),
-    tol = get_tolerance(T,kkt_system),
-    # Condensed KKT systems (Sparse + Schur) relax equalities into the barrier — the SAME
-    # systems selected for RelaxEquality below. Flooring those slacks at a tiny 1e-8 box blows up
-    # the condensation weights z/s and ruins the conditioning of the (SPD) factorization, so relax
-    # by `tol` there (feasible to ~tol, but well-conditioned). Non-condensed systems keep equalities
-    # exact and use the tight 1e-8. Must match the CUDA / ROCm extension constructors so a problem
-    # behaves identically on CPU and GPU.
-    bound_relax_factor = (kkt_system <: CondensedKKTSystems) ? tol : T(1.0e-8),
+        tol = get_tolerance(T, kkt_system),
+        # Condensed KKT systems (Sparse + Schur) relax equalities into the barrier — the SAME
+        # systems selected for RelaxEquality below. Flooring those slacks at a tiny 1e-8 box blows up
+        # the condensation weights z/s and ruins the conditioning of the (SPD) factorization, so relax
+        # by `tol` there (feasible to ~tol, but well-conditioned). Non-condensed systems keep equalities
+        # exact and use the tight 1e-8. Must match the CUDA / ROCm extension constructors so a problem
+        # behaves identically on CPU and GPU.
+        bound_relax_factor = (kkt_system <: CondensedKKTSystems) ? tol : T(1.0e-8),
 ) where {T}
     return MadNLPOptions{T}(
         tol = tol,
@@ -234,7 +234,7 @@ end
 
 get_tolerance(::Type{T},::Type{KKT}) where {T, KKT} = 10^round(log10(eps(T))/2)
 get_tolerance(::Type{T},::Type{SparseCondensedKKTSystem}) where T = 10^(round(log10(eps(T))/4))
-get_tolerance(::Type{T},::Type{SchurComplementCondensedKKTSystem}) where T = 10^(round(log10(eps(T))/4))
+get_tolerance(::Type{T}, ::Type{SchurComplementCondensedKKTSystem}) where {T} = 10^(round(log10(eps(T)) / 4))
 
 default_sparse_solver(nlp::AbstractNLPModel) = MumpsSolver
 
diff --git a/src/KKT/Schur/schur.jl b/src/KKT/Schur/schur.jl
index b93c2e0..32d5f97 100644
--- a/src/KKT/Schur/schur.jl
+++ b/src/KKT/Schur/schur.jl
@@ -107,7 +107,7 @@ struct SchurComplementCondensedKKTSystem{
     # Per-scenario sparse augmented blocks (lower triangle only)
     A_kk::Vector{SparseMatrixCSC{T, Int32}}
     C_dk::Vector{MT}                # ns × (m × blk_size) — dense, reduced to the m COUPLED
-                                    # design rows (design vars that never couple are exactly zero)
+    # design rows (design vars that never couple are exactly zero)
 
     # Sparse first-stage Schur complement (lower-triangular CSC, size nd × nd): the
     # design block S_dd plus the condensed design inequalities. SPD. Only `m` design
@@ -395,11 +395,11 @@ function _build_schur_symbolic(
     # and SPD — there is no equality saddle / bordered block.
     isempty(ind_eq) || error(
         "SchurComplementCondensedKKTSystem is RelaxEquality-only, but got $(length(ind_eq)) " *
-        "constraint(s) kept as equalities. The bordered EnforceEquality saddle that the old " *
-        "`SchurComplementKKTSystem` used was removed: the first-stage Schur complement is now SPD " *
-        "and requires every constraint to be relaxed into the barrier. Pass " *
-        "`equality_treatment=MadNLP.RelaxEquality` (the default when " *
-        "`kkt_system=SchurComplementCondensedKKTSystem`) — do not override it with `EnforceEquality`."
+            "constraint(s) kept as equalities. The bordered EnforceEquality saddle that the old " *
+            "`SchurComplementKKTSystem` used was removed: the first-stage Schur complement is now SPD " *
+            "and requires every constraint to be relaxed into the barrier. Pass " *
+            "`equality_treatment=MadNLP.RelaxEquality` (the default when " *
+            "`kkt_system=SchurComplementCondensedKKTSystem`) — do not override it with `EnforceEquality`."
     )
     ind_ineq_set = Set(Int.(ind_ineq))
 
@@ -425,7 +425,7 @@ function _build_schur_symbolic(
         if n_in_k != nc_ineq_per_s
             error(
                 "SchurComplementCondensedKKTSystem requires uniform per-scenario constraint counts. " *
-                "Scenario 1 has ineq=$nc_ineq_per_s; scenario $k has ineq=$n_in_k."
+                    "Scenario 1 has ineq=$nc_ineq_per_s; scenario $k has ineq=$n_in_k."
             )
         end
     end
@@ -921,7 +921,7 @@ function _flatten_block_maps(block_maps::Vector{ScenarioBlockMap})
     bm1 = block_maps[1]
     n_per_s_hess_Akk = length(bm1.hess_Akk_coo)
     n_per_s_hess_Cdk = length(bm1.hess_Cdk_coo)
-    n_per_s_pr_diag  = length(bm1.pr_diag_global)
+    n_per_s_pr_diag = length(bm1.pr_diag_global)
     n_per_s_ineq_Akk = length(bm1.ineq_Akk_nzpos)
     n_per_s_ineq_Cdk = length(bm1.ineq_Cdk_row)
     n_per_s_ineq_S   = length(bm1.ineq_S_row)
@@ -965,7 +965,7 @@ function _flatten_block_maps(block_maps::Vector{ScenarioBlockMap})
 end
 
 function create_kkt_system(
-    ::Type{SchurComplementCondensedKKTSystem},
+        ::Type{SchurComplementCondensedKKTSystem},
     cb::SparseCallback{T,VT},
     linear_solver::Type;
     opt_linear_solver=default_options(linear_solver),
@@ -978,9 +978,9 @@ function create_kkt_system(
         schur_var_scen = nothing,
         schur_con_scen = nothing,
     schur_scenario_linear_solver::Type=MumpsSolver,
-    schur_scenario_opt_linear_solver=default_options(schur_scenario_linear_solver),
-    schur_opt_linear_solver=nothing,
-    kwargs...,
+        schur_scenario_opt_linear_solver = default_options(schur_scenario_linear_solver),
+        schur_opt_linear_solver = nothing,
+        kwargs...,
 ) where {T, VT}
 
     isempty(kwargs) || Base.@warn(
@@ -1047,14 +1047,14 @@ function create_kkt_system(
     # (nd ≫ m) tractable on CPU.
     block_maps = [
         let bm = sym.block_maps[k]
-            ScenarioBlockMap(
-                bm.hess_Akk_coo, bm.hess_Akk_nzpos,
-                bm.hess_Cdk_coo, coupled_inv[bm.hess_Cdk_row], bm.hess_Cdk_col,
-                bm.ineq_Akk_nzpos, bm.ineq_Akk_jcoo1, bm.ineq_Akk_jcoo2, bm.ineq_Akk_bufidx,
-                coupled_inv[bm.ineq_Cdk_row], bm.ineq_Cdk_col, bm.ineq_Cdk_jcoo_d, bm.ineq_Cdk_jcoo_v, bm.ineq_Cdk_bufidx,
-                bm.ineq_S_row, bm.ineq_S_col, bm.ineq_S_jcoo1, bm.ineq_S_jcoo2, bm.ineq_S_bufidx,
-                bm.pr_diag_global, bm.pr_diag_nzpos,
-            )
+                ScenarioBlockMap(
+                    bm.hess_Akk_coo, bm.hess_Akk_nzpos,
+                    bm.hess_Cdk_coo, coupled_inv[bm.hess_Cdk_row], bm.hess_Cdk_col,
+                    bm.ineq_Akk_nzpos, bm.ineq_Akk_jcoo1, bm.ineq_Akk_jcoo2, bm.ineq_Akk_bufidx,
+                    coupled_inv[bm.ineq_Cdk_row], bm.ineq_Cdk_col, bm.ineq_Cdk_jcoo_d, bm.ineq_Cdk_jcoo_v, bm.ineq_Cdk_bufidx,
+                    bm.ineq_S_row, bm.ineq_S_col, bm.ineq_S_jcoo1, bm.ineq_S_jcoo2, bm.ineq_S_bufidx,
+                    bm.pr_diag_global, bm.pr_diag_nzpos,
+                )
         end for k in 1:ns
     ]
 
@@ -1260,7 +1260,7 @@ function factorize_kkt!(kkt::SchurComplementCondensedKKTSystem)
 end
 
 function solve_kkt!(
-    kkt::SchurComplementCondensedKKTSystem,
+        kkt::SchurComplementCondensedKKTSystem,
     w::AbstractKKTVector{T},
 ) where T
 
@@ -1359,7 +1359,7 @@ function solve_kkt!(
 end
 
 # KKT matrix-vector product for iterative refinement
-function mul!(w::AbstractKKTVector{T}, kkt::SchurComplementCondensedKKTSystem{T}, x::AbstractKKTVector, alpha = one(T), beta = zero(T)) where T
+function mul!(w::AbstractKKTVector{T}, kkt::SchurComplementCondensedKKTSystem{T}, x::AbstractKKTVector, alpha = one(T), beta = zero(T)) where {T}
     n = num_variables(kkt)
     ns_ineq = kkt.n_ineq
     wx = @view(primal(w)[1:n])
diff --git a/test/schur_test.jl b/test/schur_test.jl
index 83181ce..67ea477 100644
--- a/test/schur_test.jl
+++ b/test/schur_test.jl
@@ -184,8 +184,8 @@ using MadNLPTests
         # scenario var and the design var.
         hess_I_ok = Int32[1, 2, 3]; hess_J_ok = Int32[1, 2, 3]
         jac_I_ok  = Int32[1, 1, 2, 2]; jac_J_ok = Int32[1, 3, 2, 3]
-        ind_eq    = Int32[]
-        ind_ineq  = Int32[1, 2]
+        ind_eq = Int32[]
+        ind_ineq = Int32[1, 2]
         @test MadNLP._build_schur_symbolic(
             Float64, n, m, ns, nv, nd, nc,
             hess_I_ok, hess_J_ok, jac_I_ok, jac_J_ok, ind_eq, ind_ineq,

@codecov

codecov Bot commented Jun 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.67085% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.13%. Comparing base (4f4fb9a) to head (dd0d099).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
src/KKT/Schur/schur.jl 94.58% 17 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #623      +/-   ##
==========================================
+ Coverage   88.91%   89.13%   +0.21%     
==========================================
  Files          55       55              
  Lines        5015     5125     +110     
==========================================
+ Hits         4459     4568     +109     
- Misses        556      557       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@michel2323
michel2323 force-pushed the schur-design-constraints branch from dffdbea to e0734fb Compare June 23, 2026 16:26
@michel2323
michel2323 force-pushed the schur-design-constraints branch 8 times, most recently from 19804b7 to a84c484 Compare July 6, 2026 16:22
@michel2323
michel2323 force-pushed the schur-design-constraints branch from a84c484 to 79a9fe1 Compare July 20, 2026 14:44
… ubatch

cuDSS (through 0.8) stops reporting a usable inertia once matching is
enabled: it returns (0, 0) with info == 0 even though the factorization
is correct. InertiaBased/InertiaAuto then bump regularization forever and
dive into restoration. Recover the inertia from the sign counts of the
factor diagonal ("diag" data parameter) whenever cudss_matching is on,
using a buffer cached on the solver. Exact only for 1x1 pivots, which
holds for the quasi-definite condensed KKT systems this solver targets.

Also skip matching on uniform-batch solvers: setting matching_alg on a
ubatch solver fails the analysis phase with CUDSS_STATUS_NOT_SUPPORTED,
so the two-stage per-scenario batch solver must never enable it.
Add a sparse-callback path for SchurComplementKKTSystem covering two-stage
stochastic programs with design-only constraints. All equalities are relaxed to
condensed inequalities (slacks), so the per-scenario blocks and the first-stage
Schur complement are SPD on both CPU and GPU.

- Tag-based design/scenario layout supporting design-only constraints
  (src/KKT/Schur/schur.jl).
- GPU sparse cuDSS Schur complement: batched per-scenario factorization plus a
  sparse first-stage complement
  (lib/MadNLPGPU/ext/MadNLPGPUCUDAExt/{cuda_schur,kernels_schur,lapackgpu}.jl).
- twostage_qp instance and CPU/GPU parity tests
  (lib/MadNLPTests, test/schur_test.jl, lib/MadNLPGPU/test/schur_cuda_test.jl).
…uDSS IR 1->5

Equalities are always relaxed with slacks, matching SparseCondensedKKTSystem, so
flip the option defaults (src/IPM/options.jl). Raise the default Schur cuDSS
iterative-refinement steps 1->5: the batched per-scenario solves are inaccurate
and the error compounds through the reduction Σ_k C_dk A_kk⁻¹ C_dk', so IR is
needed for the relaxed GPU path to converge.
cuDSS 0.8 newly honors CUDSS_CONFIG_IR_TOL: with ir_n_steps > 0 AND ir_tol > 0 it arms a
hard convergence check and returns CUDSS_STATUS_IR_FAILED when iterative refinement cannot
reach ir_tol within the step budget (pre-0.8 it ignored ir_tol). The Schur path's
near-singular per-scenario block at IPM iter 0 never reaches a tight tol in a few steps, so
cuDSS aborts and crashes the solve (the refined solution is still valid).

Default cudss_ir_tol 1e-8 -> 0.0 in CudssSolverOptions and set it explicitly in
_default_schur_cudss_options(), which keeps the (load-bearing) ir_n_steps refinement passes
but disarms the convergence gate. Single-mode is unaffected (cudss_ir = 0 there, so ir_tol
is never set).
…convergence

The GPU SchurComplementKKTSystem build_kkt! used atomic-add condensation scatters whose
summation ORDER is nondeterministic. With the RelaxEquality condensation weights Σ≈1e8 (and
the constraint Hessian scaled by the large relaxed-equality duals λ), that reordering
perturbed the assembled blocks ~1e-7 run-to-run; amplified by the (legitimate) KKT
conditioning this tipped the IPM step-acceptance residual across its threshold, making
convergence a per-run roulette (~20% on the case9 SCOPF). The solve itself is bit-identical to
the CPU path given identical blocks — only the assembly was nondeterministic, while CPU
assembles sequentially.

Replace the Σ-amplified scatters (inequality condensation into A_kk / C_dk / S, the
large-valued Hessian scatters, and the Schur-reduction block) with deterministic variants: one
thread per output nz-slot sums that slot's contributions in a fixed (sorted-by-slot) order with
a plain `+=` (no two threads share a slot within a launch; launches are sequential), so the
assembled blocks are reproducible. The sorted summation is also more accurate than random
atomic order, which lowers the step residual. The per-slot segment structure depends only on
the static sparsity pattern, so it is built once and memoized in a new `det_cache` Ref field on
the kkt (no per-iteration sort; freed with the kkt). pr_diag scatters target distinct diagonal
slots (no collision) and are left as-is.

case9 (2 contingencies), tol=1e-4: GPU two-stage 20%→100% (47/47 across inertia
free/based/auto), all reaching the true optimum 5352.103 — matching the CPU path, no retry and
no loosened tolerance. case118 (176 scenarios): the one-time sort is ~330 ms (amortized; not
per-iteration), per-iteration build_kkt! ~88 ms (dominated by the cuDSS factorizations, not the
scatters; max contribution-per-slot ~60).
… Schur build

GPU two-stage Schur diverged from CPU on larger problems (case118, 176 scenarios):
the IPM pegged regularization (lg(rg)=10) and froze, while CPU converged with
near-zero regularization. Cause: build_kkt! assembled a wrong Schur complement S
(differs from CPU by ~4000x, cond 6.5e8 vs 5e4) even though A_kk and C_dk are
assembled identically (diff 0 / 1e-18) and A_kk is well-conditioned (cond 5e4).

Localized to the reduction tmp = A_kk⁻¹ C_dk_red', which is a single cuDSS
uniform-batch ("ubatch") solve with m right-hand sides per scenario. That solve
returns garbage (||tmp||~1e14 vs ~3) once the batch count ns is above ~14. The
single-RHS batched solve at the same point is exact (~1e-13), and forcing
asynchronous=false + synchronize does not help (not an async race).

Reproduced in pure CUDSS with a synthetic uniform batch: nrhs=1 is correct for all
nbatch; nrhs=54 is correct for nbatch<=12 and garbage for nbatch>=16 — independent
of the matrix. cuDSS's own batched tests only cover single-RHS, so multi-RHS ubatch
was never exercised upstream. This is a cuDSS-level bug worth reporting.

Workaround: solve the m coupled columns one at a time with the (correct) single-RHS
batched solve — column j across all ns scenarios — mirroring the CPU's
column-by-column A_kk \ C_dk'. The expensive batched factorization and the
per-scenario triangular solves stay fully batched over ns; only the build-phase
reduction loops its m RHS columns. Restore the single multi-RHS call once cuDSS
fixes multi-RHS ubatch.

Result: GPU two-stage case118 (nc=24) now SOLVE_SUCCEEDED in 34 iters with lg(rg)
healthy (→ -19.7), matching CPU's behaviour; case9 unchanged (16 iters, 5338.6725).
…ns error

PR-623 review finding 10. `check_option_sanity` still classified
`SchurComplementCondensedKKTSystem` as `is_kkt_dense`, a leftover from when its
first-stage block was factored by a dense solver. After the sparse-Schur rewrite
the first-stage complement is a sparse lower-triangular CSC on BOTH CPU
(MumpsSolver / any :csc solver) and GPU (cuDSS), and the system is driven by a
SparseCallback with no dense quasi-Newton wiring. Classifying it as dense let a
`hessian_approximation = BFGS/DampedBFGS` pass sanity and then fail later with an
obscure constructor/dispatch error. Drop Schur from `is_kkt_dense` so dense BFGS
is rejected up front, and fix the stale comment.
The literal `Union{SparseCondensedKKTSystem,
SchurComplementCondensedKKTSystem}` was copy-pasted across the IPM
option defaults and both GPU extension option constructors. Extract a
single `const CondensedKKTSystems` in options.jl and reference it (and
`MadNLP.CondensedKKTSystems` from the CUDA extension).
…ty error

PR-623 review finding 2. The `SchurComplementKKTSystem` name was kept as a silent
`const` alias while the underlying defaults flipped (EnforceEquality->RelaxEquality,
MakeParameter->RelaxBound, tighter->looser tol/bound_relax_factor), so unchanged
user scripts silently got the relaxed semantics. Replace the const with
`Base.@deprecate_binding` carrying a behavior-change message, so use of the old
name warns and points at `SchurComplementCondensedKKTSystem`.

Also improve the `_build_schur_symbolic` error raised when a user explicitly keeps
equalities (e.g. `equality_treatment=EnforceEquality`): explain that the bordered
saddle was removed and the first-stage complement is now SPD, instead of a terse
raw ErrorException.
The ROCm option constructor special-cased Schur in its
bound_relax_factor default, implying support that does not exist — there is no
ROCm Schur implementation (the batched per-scenario factorization and Schur
reduction are cuDSS/CUBLAS-specific). check_option_sanity passed and the generic
CPU create_kkt_system then ran with VT=ROCVector and failed deep in construction
with a raw MethodError/scalar-indexing error. Add a guarded ROCVector
create_kkt_system that errors early with a clear message, and drop Schur from the
ROCm bound_relax_factor union so it no longer implies support.
CPU took only `schur_scenario_linear_solver` (a solver
type, no options passthrough) while GPU took
`schur_scenario_opt_linear_solver`/`schur_opt_linear_solver` (option structs), and
neither method had a kwargs catch-all — so a `kkt_options` dict written for one
backend crashed the other with "unsupported keyword argument", and there was no
way at all to pass options to the CPU scenario solvers (they were built with
defaults).

Both methods now accept the union of the kwargs plus a `kwargs...` catch-all that
warns (instead of crashing) on inapplicable/unknown options:
- CPU additionally accepts `schur_scenario_opt_linear_solver` (now threaded into
  the per-scenario solver constructors) and `schur_opt_linear_solver`
  (GPU-only; accepted-and-ignored with a warning).
- GPU additionally accepts `schur_scenario_linear_solver` (cuDSS-only; ignored
  with a warning unless it is CUDSSSolver).
…ed columns

PR-623 review finding 7. The CPU forward elimination solved all `nd` design
columns per scenario and allocated full-width dense C_dk (nd x blk) / tmp_blk_nd
(blk x nd), though only the `m` coupled design columns are ever nonzero. For
SCOPF-shaped problems with nd >> m this meant ns*nd triangular solves per
iteration (instead of ns*m) and 2*ns dense nd x nv matrices — orders of magnitude
slower, and can exhaust memory on problems the GPU handles.

Mirror the GPU reduction: store C_dk reduced to its m coupled design rows
(m x blk) and tmp_blk_nd = A_kk^-1 C_dk_red' as (blk x m); solve only the m
coupled columns; accumulate the forward/back coupling through a contiguous reduced
buffer `rhs_d_red` (BLAS GEMV) and scatter into the coupled design rows. The Schur
reduction `mul!(schur_block, C_dk_red, tmp_red)` is now a plain contiguous GEMM
(BLAS), replacing the Vector{Int}-indexed views that hit the generic non-BLAS
fallback every iteration.

Verified: existing CPU Schur tests pass (m == nd), and a hand-checked m < nd
instance matches the SparseKKT reference to ~1e-8 at tol=1e-8.
PR-623 review cleanup. The existing two-stage test QP coupled every design
variable to a scenario (m_coupled == nd always), so the coupled-subset reduction
path (m < nd) had zero coverage. Add an `n_coupled` knob to
`build_twostage_qp_general` (scenario constraints couple only the first
`n_coupled` design vars; the rest are design-only) and a CPU testset that solves
`m < nd` instances and matches the SparseKKT reference.
After the sparse-Schur rewrite the first-stage complement
is a sparse lower-triangular CSC (factored by MumpsSolver / any :csc solver on
CPU, cuDSS on GPU), not a dense nd x nd matrix. Update docs/src/lib/kkt.md and the
CLAUDE.md architecture note accordingly, and fix the stale
`SchurComplementKKTSystem` name.
…erride

`supports_bunchkaufman_inertia(::LapackCUDASolver)=true`
(+ inertia_bunchkaufman) was justified by the dense first-stage Schur block, which
this branch removed from the GPU Schur path (both stages are now cuDSS). What
remained was an untested behaviour change flipping is_inertia false->true for every
dense-KKT + LapackCUDASolver + BUNCHKAUFMAN user (inertia-based correction plus an
O(n^2) device->host copy per factorization, and a silent mis-read if cuSOLVER's
sytrf pivot layout ever diverges from LAPACK). Revert to the AbstractLapackSolver
default (inertia-free).
… Schur)

The CudssSolverOptions struct default for cudss_ir_tol
was changed globally 1e-8 -> 0.0, affecting ALL cuDSS users, not just the Schur
path. With ir_tol=0.0 the cuDSS 0.8 CUDSS_STATUS_IR_FAILED gate is disarmed, so a
non-Schur user running cudss_ir>0 gets unconverged solves returned as success
(the IPM then proceeds with an inaccurate Newton step) instead of a loud failure.
Restore the historical 1e-8 default; the Schur-specific 0.0 override stays scoped
to _default_schur_cudss_options().
PR-623 review finding 1 (the one genuine GPU correctness bug). The GPU
compress_jacobian! used the generic non-summing transfer!
(view(jt_csc.nzVal, map) .= jac), which drops duplicate (constraint, variable)
COO coordinates last-write-wins. ExaModels-style AD emits such duplicates, and the
condensation kernels + the deterministic Hessian scatter all SUM duplicates, so
jt_csc disagreed with the assembled A_kk/C_dk/S blocks -> jtprod! (jacl = J'y),
mul!, and dual recovery used a different Jacobian than the factorized system. This
is a GPU-only inconsistency (CPU _transfer! already sums with +=), and a plausible
contributor to the case9 two-stage GPU divergence.

Fix: build a deterministic segmented SUM (jt_lin) keyed by jt_csc nzval slot and
use it in compress_jacobian!, mirroring the deterministic Hessian scatter.
PR-623 review finding 3. The GPU constructor accepted the positional
linear_solver / opt_linear_solver chosen by the IPM but hard-wired cuDSS for both
stages, silently ignoring e.g. linear_solver=LapackCUDASolver or a custom
AbstractLinearSolver. Warn loudly when linear_solver was overridden (i.e. is not
CUDSSSolver), directing users to schur_opt_linear_solver to configure the
first-stage cuDSS solver, instead of silently substituting.
… double-wrap

PR-623 review finding 9. CUDSSSolver.factorize!/solve_linear_system! caught every
CUDSS.CUDSSError and rethrew a field-less FactorizationException/SolveException,
erasing the status (ALLOC_FAILED, NOT_INITIALIZED, INTERNAL_ERROR, ...) — so a GPU
OOM was indistinguishable from numerical breakdown once mapped to
ERROR_IN_STEP_COMPUTATION. Log the cuDSS status (e.code) via the solver logger
before converting.

Also drop the redundant outer try/catch in the Schur build_kkt! forward
elimination: solve_linear_system! already converts a cuDSS failure into a
SolveException (which the IPM maps to ERROR_IN_STEP_COMPUTATION), so re-wrapping it
as a FactorizationException was dead double-handling (its CUDSS.CUDSSError branch
was unreachable).
These kernels were defined but never launched anywhere in
the repo (the assembly was rewritten to the deterministic scatters and the sparse
CSC path): _scatter_to_Cdk_batched!, _ineq_condense_{Akk,Cdk,S}_kernel!,
_init_S_{hess,diag}_kernel!, _ineq_condense_csc_kernel!, _scatter_schur_block!.
Only 7 of the 15 defined kernels were actually called; delete the 8 dead ones and
refresh the stale comment on the surviving _scatter_to_csc_atomic!.
`nd_aug` was a fossil of the removed bordered equality
block (it always equals `nd` now that the Schur system is RelaxEquality-only). It
was threaded through the symbolic return and both the CPU and GPU constructors for
no reason. Collapse it to `nd` everywhere (and rename a coincidental test-local).
`scenario_x_multi` / `scenario_b_multi` and their extra
construction-time `cudss("analysis", ...)` call were leftovers of the broken cuDSS
multi-RHS ubatch workaround: every scenario solve in build_kkt!/solve_kkt! is now
single-RHS (the multi-RHS ubatch is broken above nbatch~=14), and the batched
scenario solver is already analyzed for its single-RHS shape inside the CUDSSSolver
constructor. Drop the two struct fields, the descriptors, and the redundant
analysis. GPU Schur tests still pass 30/30.
solve_kkt! steps 3 (forward reduction) and 5 (back
substitution) ran a per-scenario `mul!` loop = ns kernel launches per solve. Replace
each with one CUBLAS.gemm_strided_batched!:
- step 3: strided-batched (m x 1) products C_dk_red[:,:,k]' rhs_k[:,k], reduced over
  the batch dim (sum!) into the reduced coupling RHS;
- step 5: broadcast the shared reduced design update across scenarios and do the whole
  back-substitution rhs_k[:,k] -= tmp_red[:,:,k] * rhs_d_red as one batched GEMM.
Two small preallocated buffers (rhs_d_red_batched (m,1,ns), rhs_d_red_mat (m,ns)) keep
it allocation-free. GPU Schur tests pass 30/30 (matches CPU reference).
`_get_det_scatter` returns the memoized condensation-scatter
structure as `::Any` (its concrete NamedTuple type is large), so accessing `det.X` in
the per-iteration hot paths dynamic-dispatched every time. Introduce function barriers:
build_kkt!, compress_hessian!, compress_jacobian!, mul!, and mul_hess_blk! each hoist
the `_get_det_scatter(kkt)` access to a thin wrapper and pass the result to a
specialized inner method, so every `det.X` access (and the kernels / SpMV taking them)
is type-stable after a single dynamic dispatch. GPU Schur tests pass 30/30.
@michel2323
michel2323 force-pushed the schur-design-constraints branch from 79a9fe1 to 6dd1a69 Compare July 20, 2026 16:51
is_inertia_correct for SchurComplementCondensedKKTSystem inspected only
the inertia of the first-stage complement S, which is what the IPM reads
off kkt.linear_solver. By Haynsworth additivity In(K) = Sum_k In(A_k) +
In(S), so In(S) == (nd, 0, 0) certifies the condensed KKT matrix only if
every per-scenario block A_k is positive definite as well.

The omission is not conservative. An indefinite A_k makes its
contribution -C_k A_k^-1 C_k' to S a positive shift, which can leave S
positive definite: S then passes, delta_w is never raised, and the IPM
accepts a direction with wrong curvature. Measured on a two-stage SCOPF
model with ns = 176, 103-161 blocks were indefinite on most iterations
while S stayed SPD; the solve ran 165 iterations at ir = 1, hit
restoration and went NaN.

build_kkt! now aggregates the block inertias into three counters on the
KKT while it factorizes, and is_inertia_correct requires them to be
zero. This costs nothing: MUMPS already computes the negative-pivot
count in INFOG(12) during that same factorization, so the counters are
a read rather than work. delta_w needed no change either --
regularize_diagonal! already feeds pr_diag into every A_k as well as
into S_dd; only the trigger was broken.

Also add is_inertia(::AbstractKKTSystem), defaulting to the capability
of the KKT's linear solver, and switch InertiaAuto to dispatch on it. A
KKT system whose matrix is factorized by more than one solver can now
report that it certifies inertia only when all of them do; the Schur
override requires the complement plus all ns block solvers. Blocks that
cannot report inertia are skipped in the aggregation, so InertiaAuto
falls back to InertiaFree and a forced InertiaBased raises
InertiaException rather than silently certifying nothing.

The ns = 176 case goes from NaN-at-restoration to SOLVE_SUCCEEDED,
matching the equivalent single-block formulation to 5e-15 relative in
the same 47 iterations and 105 factorizations.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant