Batched Solving¶
CuPIQP is natively batched. A single solver instance solves B independent QPs in
one solver call.
Batch as the leading dimension¶
CuPIQP is natively designed to solve batched problems. Therefore, the problem data should be passed as batchl, and every array in the results has the batch size as its leading dimension.
| field | shape |
|---|---|
P, c |
(B, n, n), (B, n) |
A, b |
(B, p, n) , (B, p) |
G, h_l, h_u |
(B, m, n) , (B, m), (B, m) |
x_l, x_u |
(B, n), (B, n) |
The solver detects the batch size at setup() time from the input shape.
Each problem in the batch carries its own information and converges independently. Read per-problem diagnostics from solver.result.info — every field
is a (B,) array. See Results & Status.
solver = DenseSolver()
solver.setup(
P=P_batch, # 3-dim array, with batch size as leading dim
c=c_batch, # 2-dim array, with batch size as leading dim
A=A_batch, # 3-dim array, with batch size as leading dim
b=b_batch, # 2-dim array, with batch size as leading dim
G=G_batch, # 3-dim array, with batch size as leading dim
h_l=h_l_batch, # 2-dim array, with batch size as leading dim
h_u=h_u_batch, # 2-dim array, with batch size as leading dim
x_l=x_l_batch, # 2-dim array, with batch size as leading dim
x_u=x_u_batch, # 2-dim array, with batch size as leading dim
)
solver.solve()
x_sol = solver.result.x # cupy array of shape (B, n)
status = solver.result.info.status # list of length B
for i, st in enumerate(status):
print(f"problem {i}: status = {status.name}, x = {x_sol[i]}")
Single problem case:¶
A single problem is simply the B=1 case. CuPIQP accepts problem data as single problem, i.e., without the batch size dimension.
However, cuPIQP internally treat this case as B=1 and the returned result still carries the batch size 1 as the leading dimension.
P_single = cp.eye(2) # 2-dim array, no batch dim
c_single = cp.ones(2) # 1-dim array, no batch dim
A_single = cp.array([[1.0, -2.0]]) # 2-dim array, no batch dim
b_single = cp.array([1.0]) # 1-dim array, no batch dim
solver = DenseSolver()
solver.setup(
P=P_single, c=c_single,
A=A_single, b=b_single
)
solver.solve()
# result.x still carries a leading batch dimension (B, n); here B = 1
x_sol = solver.result.x[0]
Sparse problems¶
For the sparse problems, pass P, A, G as one of the following types:
- cupyx.scipy.sparse.csr_matrix. It only works for batch size 1.
- List or tuple of cupyx.scipy.sparse.csr_matrix objects. However, it is discouraged when the batch size is big because this can cause long setup time and low performance.
- UniformBatchedCsrMatrix. This is a class introduced and used internally by cuPIQP itself to represent a batch of sparse CSR matrices with uniform sparsity. It has indices and indptr attributes which are two cupy.ndarray of shape (nnz,) to represent the sparsity pattern. Its data attribute is a cupy.ndarray of shape (batch_size, nnz) that stores the non-zeros values of all matrices in the batch. We encourage users to import it from cuPIQP to store their batched CSR matrices and pass to the SparseSolver.
- torch.sparse_csr_tensor, which is the CSR matrix representation in Torch and can express batched CSR matrices. CuPIQP requires that crow_indices and col_indices must be identical for all matrices to enforce uniform sparsity.
The vectors \(c, b, h_l, h_u, x_l, x_u\) stay stacked (B, ...) dense arrays just like the dense case.
See this example for more details.
Uniform structure across the batch¶
All problems in a batch share the same structure, even though their numerical data differ:
- Same shapes
n,p,mand (for the sparse backend) the same sparsity pattern. - For sparse solver, all \(P\) matrices in the batch must have the same sparsity pattern. This also applies to \(A\) and \(G\).
Bound patterns can differ per problem
There are two separate questions about bounds, and they behave differently:
1. Which entries are finite — free to differ per problem, and changeable between solves.
Within a bound array you pass, any entry set to ±inf means "no bound there". This
pattern can be different for each problem in the batch, and you can change it later with
update() without calling setup() again. For example, in a batch of 2 problems with
m = 2 inequality rows:
# problem 0: row 0 is one-sided (only upper), row 1 is two-sided
# problem 1: both rows two-sided
h_l = cp.array([[-cp.inf, -3.0], # problem 0
[ -2.0, -3.0]]) # problem 1
h_u = cp.array([[ 1.0, 4.0],
[ 1.0, 4.0]])
cuPIQP keeps a full-length slot for every row and just ignores the ±inf ones, so no
common pattern is needed.
2. Which bound sides exist — fixed at setup(), shared by the whole batch.
Each of the four sides h_l, h_u, x_l, x_u is either passed at setup() or left
out (None). A side you leave out is gone for every problem in the batch and uses no
memory (its result duals/slacks are (B, 0)); you cannot add it back later without a
fresh setup(). So if you only ever need an upper inequality, pass h_u and omit h_l
entirely. If some problems need the lower side, instead keep h_l present for the
whole batch and set it to -inf on the problems that don't (case 1 above).