Backends¶
cuPIQP runs the same proximal interior-point algorithm regardless of how the KKT
linear systems are factorized. The backend is chosen by picking the matching
type-strict solver class, each of which enforces a one-to-one mapping between its
KKT factorization and the storage category of your P / A / G inputs.
| Solver | Matrices P, A, G |
KKT backend | Use when |
|---|---|---|---|
DenseSolver |
dense cupy arrays |
dense Cholesky | small-to-medium, dense problems |
SparseSolver |
UniformBatchedCsrMatrix (or CSR) |
sparse LDLᵀ (cuDSS) | large, structurally sparse problems |
MultistageSolver |
block-structured objects | block Cholesky | block-tridiagonal/-arrow KKT (e.g. OCPs) |
All three accept GPU-resident inputs only and share the same setup / solve /
update workflow and Settings. See
Re-solving with new data for the
fixed-structure update pattern.
GPU arrays only — no silent host copies
Every non-None input must already be a GPU array. CPU arrays
(numpy.ndarray, CPU torch tensors, CPU JAX arrays) are rejected with an
actionable TypeError rather than copied to the device. Convert first:
Dense inputs are accepted via the
__cuda_array_interface__
protocol, which unifies CuPy, CUDA torch.Tensor, CUDA JAX arrays, and Numba CUDA
device arrays behind one check.
DenseSolver¶
The dense backend factorizes a condensed KKT matrix with a batched dense Cholesky. It is the right choice for small-to-medium problems whose matrices are essentially dense.
import cupy as cp
from cupiqp import DenseSolver
P = cp.eye(4)
c = cp.zeros(4)
s = DenseSolver()
s.setup(P=P, c=c)
s.solve()
P,A,Gmust be dense GPU arrays (2D for a single problem, 3D(B, …)for a batch).- The vector inputs (
c,b,h_l,h_u,x_l,x_u) are dense GPU vectors.
SparseSolver¶
The sparse backend uses a sparse LDLᵀ direct factorization (cuDSS) and is far more efficient than the dense backend for large, structurally sparse problems.
from cupyx.scipy.sparse import csr_matrix
from cupiqp import SparseSolver
s = SparseSolver()
s.setup(
P=csr_matrix(P), c=c,
A=csr_matrix(A), b=b,
G=csr_matrix(G), h_l=h_l, h_u=h_u,
)
s.solve()
P,A,Gare GPU CSR matrices (cupyx.scipy.sparse.csr_matrix); a single CSR is treated as theB = 1case.- For a batch, the preferred input is a
UniformBatchedCsrMatrix— cuPIQP's own container holdingBmatrices that share one sparsity pattern, with the values stacked as a(B, nnz)array. The vectors are stacked(B, …).
from cupiqp import UniformBatchedCsrMatrix
# Pack each shared-pattern matrix into a (B, nnz) batched container.
# from_cupy_csr_matrix replicates one CSR across the batch; for differing
# per-problem values, build with the UniformBatchedCsrMatrix(B, indices, indptr,
# values, shape=...) constructor instead (see Getting Started).
P_b = UniformBatchedCsrMatrix.from_cupy_csr_matrix(csr_matrix(P), batch_size=B)
A_b = UniformBatchedCsrMatrix.from_cupy_csr_matrix(csr_matrix(A), batch_size=B)
G_b = UniformBatchedCsrMatrix.from_cupy_csr_matrix(csr_matrix(G), batch_size=B)
s.setup(P=P_b, c=c_b, A=A_b, b=b_b, G=G_b, h_l=h_l_b, h_u=h_u_b)
Avoid passing a raw list of csr_matrix
setup also accepts a plain list of B CSR matrices that share one pattern, but
separate matrix objects lack the uniform stride batched routines need, so cuPIQP must
copy them into a UniformBatchedCsrMatrix at setup. Build and pass one yourself to
skip that copy. See Getting Started for the full example.
Bit-reproducible cuDSS
Set settings.use_deterministic_mode_for_cudss = True for bit-wise reproducible
sparse factorizations (somewhat slower). See Settings.
MultistageSolver¶
The multistage backend exploits block-tridiagonal / block-tridiagonal-arrow KKT
structure — the structure that arises in optimal control problems (OCPs) and other
multistage programs — with a block Cholesky factorization. It requires the
socu extra (install with
pip install ".[cuda13,multistage]").
It accepts block-structured storage end-to-end: generic CSR is not auto-promoted to block form, because if you have not built the block matrices the multistage solver cannot exploit the structure anyway.
from cupiqp import MultistageSolver
from cupiqp.multistage.multistage_utils import (
BlockTridiagMat, BlockBidiagMat, BlockVec,
)
P = BlockTridiagMat(num_diag_blocks=N, block_size=d)
A = BlockBidiagMat(rows_of_blocks=d, cols_of_blocks=d, N=N)
c = BlockVec(num_blocks=N, rows=d)
b = BlockVec(num_blocks=N, rows=d)
# ... fill block data ...
s = MultistageSolver()
s.setup(P=P, c=c, A=A, b=b)
s.solve()
| Input | Type |
|---|---|
P |
BlockTridiagMat |
A, G |
BlockBidiagMat (or None) |
c, b, h_u, h_l, x_u, x_l |
BlockVec (or None) |
Kernel strategy¶
cuPIQP runs the same interior-point algorithm at every problem size; only the
inner-loop kernel implementation changes, and the solver picks it automatically at
setup() — there is no setting to tune and nothing to change in your code. The inner
loop (step length, barrier parameter mu, centering sigma, residual and merit
evaluation) is evaluated one of two ways:
- Fused Warp tile kernels — JIT-compiled and specialized to the problem dimensions, very fast per launch, and they amortize across a batch and across IPM iterations. The catch is the compile step: because the kernels are specialized to the problem width, the first-solve compile time grows with the problem and eventually dominates — you can spend more time compiling kernels than actually solving.
- CuPy axis-reduction kernels (
cp.min,cp.sum,cp.maxover the data axis) — generic, so they need no shape-specialized compilation and the compile cliff disappears. They carry more per-launch overhead, but over a long reduction axis — i.e. a wide problem — that overhead is amortized and the trade is worth it. This path also builds the Ruiz preconditioner with the tile kernels switched off, for the same reason.
The choice is made from the problem width at setup(): with
tile_width = max(n + p + m, p + num_ineq), the solver uses the CuPy axis-reduction
kernels when tile_width >= 1024 and the fused Warp tile kernels otherwise, where
num_ineq is the total number of finite inequality + box-bound rows. It depends
on width only — independent of batch size and dtype, because the thing it avoids
(tile-kernel compile time) depends on shape, not on how many problems you batch. The
threshold is an internal heuristic, not a precisely calibrated constant, and both paths
run the same algorithm and agree to solver tolerance.
Batched workloads use the tile kernels
The selection is by problem width, not batch size: a large batch of moderately-wide problems still uses the fused tile kernels (they amortize across the batch), which is the intended behavior. The CuPy path targets a single (or small-batch) wide problem where compile time of warp tile-based kernels would dominate the run time.