Skip to content

Commit a6bdefb

Browse files
authored
CuClarabel.jl interface (cvxpy#2812)
* Work on interface * Finishes interface * Deletes dead code * Fixes CI * Work on CuClarabel * Starts docs * Adds docs deletes dead code
1 parent ae6fd71 commit a6bdefb

File tree

6 files changed

+332
-3
lines changed

6 files changed

+332
-3
lines changed

cvxpy/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from cvxpy.settings import (
6464
CBC as CBC,
6565
CLARABEL as CLARABEL,
66+
CUCLARABEL as CUCLARABEL,
6667
COPT as COPT,
6768
CPLEX as CPLEX,
6869
CPP_CANON_BACKEND as CPP_CANON_BACKEND,
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""
2+
Copyright 2022, the CVXPY Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
17+
"""
18+
import numpy as np
19+
import scipy.sparse as sp
20+
21+
import cvxpy.settings as s
22+
from cvxpy.constraints import SOC, ExpCone, PowCone3D
23+
from cvxpy.reductions.solution import Solution, failure_solution
24+
from cvxpy.reductions.solvers import utilities
25+
from cvxpy.reductions.solvers.conic_solvers.conic_solver import ConicSolver
26+
from cvxpy.utilities.citations import CITATION_DICT
27+
28+
29+
def dims_to_solver_cones(jl, cone_dims):
30+
31+
jl.seval("""cones = Clarabel.SupportedCone[]""")
32+
33+
# assume that constraints are presented
34+
# in the preferred ordering of SCS.
35+
36+
if cone_dims.zero > 0:
37+
jl.push_b(jl.cones, jl.Clarabel.ZeroConeT(cone_dims.zero))
38+
39+
if cone_dims.nonneg > 0:
40+
jl.push_b(jl.cones, jl.Clarabel.NonnegativeConeT(cone_dims.nonneg))
41+
42+
for dim in cone_dims.soc:
43+
jl.push_b(jl.cones, jl.Clarabel.SecondOrderConeT(dim))
44+
45+
for dim in cone_dims.psd:
46+
jl.push_b(jl.cones, jl.Clarabel.PSDTriangleConeT(dim))
47+
48+
for _ in range(cone_dims.exp):
49+
jl.push_b(jl.cones, jl.Clarabel.ExponentialConeT())
50+
51+
for pow in cone_dims.p3d:
52+
jl.push_b(jl.cones, jl.Clarabel.PowerConeT(pow))
53+
54+
55+
class CUCLARABEL(ConicSolver):
56+
"""An interface for the Clarabel solver.
57+
"""
58+
59+
# Solver capabilities.
60+
MIP_CAPABLE = False
61+
SUPPORTED_CONSTRAINTS = ConicSolver.SUPPORTED_CONSTRAINTS \
62+
+ [SOC, ExpCone, PowCone3D]
63+
64+
STATUS_MAP = {
65+
"SOLVED": s.OPTIMAL,
66+
"PRIMAL_INFEASIBLE": s.INFEASIBLE,
67+
"DUAL_INFEASIBLE": s.UNBOUNDED,
68+
"ALMOST_SOLVED": s.OPTIMAL_INACCURATE,
69+
"ALMOST_PRIMAL_INFEASIBLE": s.INFEASIBLE_INACCURATE,
70+
"Almost_DUAL_INFEASIBLE": s.UNBOUNDED_INACCURATE,
71+
"MAX_ITERATIONS": s.USER_LIMIT,
72+
"MAX_TIME": s.USER_LIMIT,
73+
"NUMERICAL_ERROR": s.SOLVER_ERROR,
74+
"INSUFFICIENT_PROGRESS": s.SOLVER_ERROR
75+
}
76+
77+
# Order of exponential cone arguments for solver.
78+
EXP_CONE_ORDER = [0, 1, 2]
79+
80+
def name(self):
81+
"""The name of the solver.
82+
"""
83+
return 'CUCLARABEL'
84+
85+
def import_solver(self) -> None:
86+
"""Imports the solver.
87+
"""
88+
# Does NOT import juliacall because that starts a Julia interpreter
89+
# and this method is called on CVXPY importing and that's too heavy.
90+
import cupy # noqa F401
91+
92+
def supports_quad_obj(self) -> bool:
93+
"""Clarabel supports quadratic objective with any combination
94+
of conic constraints.
95+
"""
96+
return True
97+
98+
@staticmethod
99+
def extract_dual_value(result_vec, offset, constraint):
100+
"""Extracts the dual value for constraint starting at offset.
101+
"""
102+
return utilities.extract_dual_value(result_vec, offset, constraint)
103+
104+
def invert(self, solution, inverse_data):
105+
"""Returns the solution to the original problem given the inverse_data.
106+
"""
107+
108+
attr = {}
109+
status = self.STATUS_MAP[str(solution.status)]
110+
attr[s.SOLVE_TIME] = solution.solve_time
111+
attr[s.NUM_ITERS] = solution.iterations
112+
# more detailed statistics here when available
113+
# attr[s.EXTRA_STATS] = solution.extra.FOO
114+
115+
if status in s.SOLUTION_PRESENT:
116+
primal_val = solution.obj_val
117+
opt_val = primal_val + inverse_data[s.OFFSET]
118+
primal_vars = {
119+
inverse_data[CUCLARABEL.VAR_ID]: np.array(solution.x)
120+
}
121+
eq_dual_vars = utilities.get_dual_values(
122+
np.array(solution.z[:inverse_data[ConicSolver.DIMS].zero]),
123+
self.extract_dual_value,
124+
inverse_data[CUCLARABEL.EQ_CONSTR]
125+
)
126+
ineq_dual_vars = utilities.get_dual_values(
127+
np.array(solution.z[inverse_data[ConicSolver.DIMS].zero:]),
128+
self.extract_dual_value,
129+
inverse_data[CUCLARABEL.NEQ_CONSTR]
130+
)
131+
dual_vars = {}
132+
dual_vars.update(eq_dual_vars)
133+
dual_vars.update(ineq_dual_vars)
134+
return Solution(status, opt_val, primal_vars, dual_vars, attr)
135+
else:
136+
return failure_solution(status, attr)
137+
138+
def solve_via_data(self, data, warm_start: bool, verbose: bool, solver_opts, solver_cache=None):
139+
"""Returns the result of the call to the solver.
140+
141+
Parameters
142+
----------
143+
data : dict
144+
Data generated via an apply call.
145+
warm_start : Bool
146+
Whether to warm_start Clarabel.
147+
verbose : Bool
148+
Control the verbosity.
149+
solver_opts : dict
150+
Clarabel-specific solver options.
151+
152+
Returns
153+
-------
154+
The result returned by a call to clarabel.solve().
155+
"""
156+
import cupy
157+
from cupyx.scipy.sparse import csr_matrix as cucsr_matrix
158+
from juliacall import Main as jl
159+
jl.seval('using Clarabel, LinearAlgebra, SparseArrays')
160+
jl.seval('using CUDA, CUDA.CUSPARSE')
161+
162+
A = data[s.A]
163+
b = data[s.B]
164+
q = data[s.C]
165+
166+
if s.P in data:
167+
P = data[s.P]
168+
else:
169+
nvars = q.size
170+
P = sp.csr_array((nvars, nvars))
171+
172+
P = sp.triu(P).tocsr()
173+
174+
cones = data[ConicSolver.DIMS]
175+
176+
Pgpu = cucsr_matrix(P)
177+
qgpu = cupy.array(q)
178+
179+
Agpu = cucsr_matrix(A)
180+
bgpu = cupy.array(b)
181+
182+
if Pgpu.nnz != 0:
183+
jl.P = jl.Clarabel.cupy_to_cucsrmat(
184+
jl.Float64, int(Pgpu.data.data.ptr), int(Pgpu.indices.data.ptr),
185+
int(Pgpu.indptr.data.ptr), *Pgpu.shape, Pgpu.nnz)
186+
else:
187+
jl.seval(f"""
188+
P = CuSparseMatrixCSR(sparse(Float64[], Float64[], Float64[], {nvars}, {nvars}))
189+
""")
190+
jl.q = jl.Clarabel.cupy_to_cuvector(jl.Float64, int(qgpu.data.ptr), qgpu.size)
191+
192+
jl.A = jl.Clarabel.cupy_to_cucsrmat(
193+
jl.Float64, int(Agpu.data.data.ptr), int(Agpu.indices.data.ptr),
194+
int(Agpu.indptr.data.ptr), *Agpu.shape, Agpu.nnz)
195+
jl.b = jl.Clarabel.cupy_to_cuvector(jl.Float64, int(bgpu.data.ptr), bgpu.size)
196+
197+
198+
dims_to_solver_cones(jl, cones)
199+
200+
results = jl.seval("""
201+
settings = Clarabel.Settings(direct_solve_method = :cudss)
202+
solver = Clarabel.Solver(P,q,A,b,cones, settings)
203+
Clarabel.solve!(solver)
204+
""")
205+
return results
206+
207+
def cite(self, data):
208+
"""Returns bibtex citation for the solver.
209+
210+
Parameters
211+
----------
212+
data : dict
213+
Data generated via an apply call.
214+
"""
215+
return CITATION_DICT["CUCLARABEL"]

cvxpy/reductions/solvers/defines.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from cvxpy.reductions.solvers.conic_solvers.clarabel_conif import CLARABEL as CLARABEL_con
2323
from cvxpy.reductions.solvers.conic_solvers.copt_conif import COPT as COPT_con
2424
from cvxpy.reductions.solvers.conic_solvers.cplex_conif import CPLEX as CPLEX_con
25+
from cvxpy.reductions.solvers.conic_solvers.cuclarabel_conif import CUCLARABEL as CUCLARABEL_con
2526
from cvxpy.reductions.solvers.conic_solvers.cvxopt_conif import CVXOPT as CVXOPT_con
2627

2728
# Conic interfaces
@@ -61,7 +62,7 @@
6162
GLPK_MI_con(), CBC_con(), CLARABEL_con(), SCS_con(), SDPA_con(),
6263
GUROBI_con(), MOSEK_con(), CPLEX_con(), NAG_con(), XPRESS_con(),
6364
SCIP_con(), SCIPY_con(), HIGHS_con(), GLOP_con(), PDLP_con(),
64-
QOCO_con(), ECOS_BB_con()]
65+
QOCO_con(), CUCLARABEL_con(), ECOS_BB_con()]
6566
solver_qp_intf = [OSQP_qp(),
6667
GUROBI_qp(),
6768
CPLEX_qp(),
@@ -84,7 +85,7 @@
8485
s.CPLEX, s.GUROBI, s.COPT, s.GLPK, s.NAG,
8586
s.GLPK_MI, s.CBC, s.CVXOPT, s.XPRESS, s.DIFFCP,
8687
s.SCIP, s.SCIPY, s.HIGHS, s.GLOP, s.PDLP, s.QOCO,
87-
s.ECOS_BB]
88+
s.CUCLARABEL, s.ECOS_BB]
8889
QP_SOLVERS = [s.OSQP,
8990
s.GUROBI,
9091
s.CPLEX,

cvxpy/settings.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,15 @@
9292
SCIP = "SCIP"
9393
SCIPY = "SCIPY"
9494
CLARABEL = "CLARABEL"
95+
CUCLARABEL = "CUCLARABEL"
9596
DAQP = "DAQP"
9697
HIGHS = "HIGHS"
9798
MPAX = "MPAX"
9899
SOLVERS = [CLARABEL, ECOS, CVXOPT, GLOP, GLPK, GLPK_MI,
99100
SCS, SDPA, GUROBI, OSQP, CPLEX,
100101
MOSEK, CBC, COPT, XPRESS, PIQP, PROXQP, QOCO,
101-
NAG, PDLP, SCIP, SCIPY, DAQP, HIGHS, MPAX]
102+
NAG, PDLP, SCIP, SCIPY, DAQP, HIGHS, MPAX,
103+
CUCLARABEL]
102104

103105
# Xpress-specific items
104106
XPRESS_IIS = "XPRESS_IIS"

cvxpy/tests/test_conic_solvers.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,114 @@ def test_clarabel_sdp_2(self) -> None:
543543
sth.check_complementarity(places)
544544
sth.check_dual_domains(places)
545545

546+
@unittest.skipUnless('CUCLARABEL' in INSTALLED_SOLVERS, 'CLARABEL is not installed.')
547+
class TestCuClarabel(BaseTest):
548+
549+
""" Unit tests for Clarabel. """
550+
def setUp(self) -> None:
551+
552+
self.x = cp.Variable(2, name='x')
553+
self.y = cp.Variable(3, name='y')
554+
555+
self.A = cp.Variable((2, 2), name='A')
556+
self.B = cp.Variable((2, 2), name='B')
557+
self.C = cp.Variable((3, 2), name='C')
558+
559+
def test_clarabel_parameter_update(self) -> None:
560+
"""Test warm start.
561+
"""
562+
x = cp.Variable(2)
563+
P = cp.Parameter(nonneg=True),
564+
A = cp.Parameter(4)
565+
b = cp.Parameter(2, nonneg=True)
566+
q = cp.Parameter(2)
567+
568+
def update_parameters(P, A, b, q):
569+
P[0].value = np.random.rand()
570+
A.value = np.random.randn(4)
571+
b.value = np.random.rand(2)
572+
q.value = np.random.randn(2)
573+
574+
prob = cp.Problem(
575+
cp.Minimize(P[0]*cp.square(x[0]) + cp.quad_form(x, np.ones([2, 2])) + q.T @ x),
576+
[A[0] * x[0] + A[1] * x[1] == b[0],
577+
A[2] * x[0] + A[3] * x[1] <= b[1]]
578+
)
579+
580+
update_parameters(P, A, b, q)
581+
result1 = prob.solve(solver=cp.CLARABEL, warm_start=False)
582+
result2 = prob.solve(solver=cp.CLARABEL, warm_start=True)
583+
self.assertAlmostEqual(result1, result2)
584+
585+
update_parameters(P, A, b, q)
586+
result1 = prob.solve(solver=cp.CLARABEL, warm_start=True)
587+
result2 = prob.solve(solver=cp.CLARABEL, warm_start=False)
588+
self.assertAlmostEqual(result1, result2)
589+
590+
# consecutive solves, no data update
591+
result1 = prob.solve(solver=cp.CLARABEL, warm_start=False)
592+
self.assertAlmostEqual(result1, result2)
593+
594+
595+
def test_clarabel_lp_0(self) -> None:
596+
StandardTestLPs.test_lp_0(solver=cp.CUCLARABEL)
597+
598+
def test_clarabel_nonstandard_name(self) -> None:
599+
# Test that solver name with non-standard capitalization works.
600+
StandardTestLPs.test_lp_0(solver="CuClarabel")
601+
602+
def test_clarabel_lp_1(self) -> None:
603+
StandardTestLPs.test_lp_1(solver='CUCLARABEL')
604+
605+
def test_clarabel_lp_2(self) -> None:
606+
StandardTestLPs.test_lp_2(solver='CUCLARABEL')
607+
608+
def test_clarabel_lp_3(self) -> None:
609+
StandardTestLPs.test_lp_3(solver='CUCLARABEL')
610+
611+
def test_clarabel_lp_4(self) -> None:
612+
StandardTestLPs.test_lp_4(solver='CUCLARABEL')
613+
614+
def test_clarabel_lp_5(self) -> None:
615+
StandardTestLPs.test_lp_5(solver='CUCLARABEL')
616+
617+
def test_clarabel_qp_0(self) -> None:
618+
StandardTestQPs.test_qp_0(solver='CUCLARABEL')
619+
620+
def test_clarabel_qp_0_linear_obj(self) -> None:
621+
StandardTestQPs.test_qp_0(solver='CUCLARABEL', use_quad_obj=False)
622+
623+
def test_clarabel_socp_0(self) -> None:
624+
StandardTestSOCPs.test_socp_0(solver='CUCLARABEL')
625+
626+
def test_clarabel_socp_1(self) -> None:
627+
StandardTestSOCPs.test_socp_1(solver='CUCLARABEL')
628+
629+
def test_clarabel_socp_2(self) -> None:
630+
StandardTestSOCPs.test_socp_2(solver='CUCLARABEL')
631+
632+
def test_clarabel_socp_3(self) -> None:
633+
# axis 0
634+
breakpoint()
635+
StandardTestSOCPs.test_socp_3ax0(solver='CUCLARABEL')
636+
# axis 1
637+
StandardTestSOCPs.test_socp_3ax1(solver='CUCLARABEL')
638+
639+
def test_clarabel_expcone_1(self) -> None:
640+
StandardTestECPs.test_expcone_1(solver='CUCLARABEL')
641+
642+
def test_clarabel_exp_soc_1(self) -> None:
643+
StandardTestMixedCPs.test_exp_soc_1(solver='CUCLARABEL')
644+
645+
def test_clarabel_pcp_0(self) -> None:
646+
StandardTestSOCPs.test_socp_0(solver='CUCLARABEL')
647+
648+
def test_clarabel_pcp_1(self) -> None:
649+
StandardTestSOCPs.test_socp_1(solver='CUCLARABEL')
650+
651+
def test_clarabel_pcp_2(self) -> None:
652+
StandardTestSOCPs.test_socp_2(solver='CUCLARABEL')
653+
546654

547655
@unittest.skipUnless('MOSEK' in INSTALLED_SOLVERS, 'MOSEK is not installed.')
548656
class TestMosek(unittest.TestCase):

doc/source/tutorial/solvers/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ The table below shows the types of problems the supported solvers can handle.
9797
+----------------+----+----+------+-----+-----+-----+-----+
9898
| `MPAX`_ | X | X | | | | | |
9999
+----------------+----+----+------+-----+-----+-----+-----+
100+
| `CUCLARABEL`_ | X | X | X | | X | X | |
101+
+----------------+----+----+------+-----+-----+-----+-----+
100102
| `CVXOPT`_ | X | X | X | X | | | |
101103
+----------------+----+----+------+-----+-----+-----+-----+
102104
| `SDPA`_ \*\*\* | X | X | X | X | | | |

0 commit comments

Comments
 (0)