Skip to content

Solution-Map Layer (SolMapLayer)

SolMapLayer evaluates the solution map \(\hat z^\star=\mathcal{O}(\hat y)\) and connects the returned primal variables to PyTorch autograd. Here, \(\hat y\) denotes the parameters supplied to the optimization problem and \(\hat z^\star\) denotes the corresponding optimal decisions. Layer remains an exact alias for backward compatibility:

from diffapqp import Layer, SolMapLayer

assert SolMapLayer is Layer

Accepted problems

The constructor accepts an ordinary cvxpy.Problem when it can be canonicalized to a standard APQP/APLP problem with all of the following properties:

  • a minimization problem with at least one named parameter and one named decision variable
  • DCP and QP compliant, and DPP compliant when parameters are present
  • continuous variables only
  • a constant positive-semidefinite quadratic matrix \(P\)
  • constant equality and inequality coefficient matrices \(A\) and \(G\)
  • parameters entering affinely through the linear objective, objective offset, or constraint right-hand sides

DiffAPQP rejects parameterized \(P\), \(A\), or \(G\), and formulations for which CVXPY canonicalization introduces, removes, or resizes decision variables. Names should therefore be unique and should be assigned explicitly to every parameter and variable used through the public dictionaries. See APQP and Canonicalization for the supported APQP/APLP form.

No manual standardization required

Write the model naturally with CVXPY and pass the original cvxpy.Problem to SolMapLayer. DiffAPQP performs the compatible canonicalization and standard-form extraction internally.

Constructor

SolMapLayer(
    problem,
    max_n_cores=os.cpu_count(),
    regularization_eps=0.0,
    act_ineq_tol=None,
    act_ineq_tol_rel=None,
)

problem : cvxpy.Problem : Supported continuous APQP/APLP problem described above.

max_n_cores : int, default os.cpu_count() : Maximum number of outer forward and backward sample workers. 1 selects serial execution and -1 is converted to os.cpu_count(). Requested worker counts are capped at the CPU count reported by the operating system. This setting does not establish CPU affinity.

regularization_eps : float, default 0.0 : Nonnegative coefficient used to replace the supplied objective \(f_0(z,\hat y)\) with

\[ f_{0,\epsilon}(z,\hat y) = f_0(z,\hat y) + \frac{\epsilon}{2}\sum_j \lVert z_j\rVert_2^2. \]

This can select a better-conditioned or unique solution, especially for an LP, but the returned solution \(\hat z_\epsilon^\star\) and its derivative then belong to the regularized problem. See Quadratic regularization for its mathematical interpretation.

act_ineq_tol : float | None, default None : Nonnegative absolute tolerance used to identify active inequalities for the reduced-KKT adjoint.

act_ineq_tol_rel : float | None, default None : Nonnegative relative tolerance used to identify active inequalities for the reduced-KKT adjoint.

See Adjoint configuration for more details.

Construct a layer once and reuse it across calls. Construction canonicalizes the problem and creates the configured forward worker pool. Linux retains the fork-based backward process pool used by the benchmarked implementation. Windows and macOS use a shared-memory thread pool for parallel adjoint solves, avoiding process startup and problem-data serialization. The thread backend does not require an if __name__ == "__main__": guard.

Persistent worker pools can be released explicitly with layer.close(). SolMapLayer can also be used as a context manager when its lifetime is limited to a block.

Forward call

primal_dict, dual_eq, dual_ineq, raw_solutions, time_batch = layer(
    param_dict,
    idx_list=None,
    warm_start=False,
    update=False,
    solver="OSQP",
    verbose=False,
    solver_args={},
    reduced_kkt=False,
    mode=None,
    mode_args={},
)

Batched parameters

param_dict maps every original CVXPY parameter name to a floating-point torch.Tensor. Every tensor must have the same leading batch dimension. Mathematically, one batch row supplies a value of \(\hat y\). DiffAPQP canonicalizes that instance, solves for the standard-form decision \(\tilde z^\star\), and retrieves the original named decisions \(\hat z^\star=\mathcal{R}(\tilde z^\star)\) in primal_dict.

Original CVXPY shape Accepted batched shape
scalar () (batch,) or (batch, 1)
vector (n,) (batch, n)
matrix (m, n) (batch, m, n)
higher-dimensional shape (batch, *parameter.shape)

Matrix and higher-dimensional values are flattened internally using CVXPY's column-major convention and restored to their original shapes during backward. The current solver path is CPU based: inputs are converted to NumPy for CVXPY, and public outputs are CPU objects. Use a consistent floating dtype for all parameters.

The first forward call creates one reusable CVXPY problem per batch slot and therefore establishes the largest supported batch size for that layer instance. Later calls may use the same or a smaller batch, but should not exceed the first batch size.

Forward options

idx_list : list[int] | None, default None : Stable dataset ids corresponding one-to-one with the current batch. It is required when warm_start=True for OSQP, SCS, QPALM, or PROXQP and must have the same length and order as the batch.

warm_start : bool, default False : Request a warm start. For the custom solver paths, DiffAPQP stores solver-native iterates by sample id after each successful call.

update : bool, default False : Request in-place solver-data updates. The first call for a batch slot builds the solver workspace; update becomes effective on later calls.

solver : str, default "OSQP" : Case-insensitive CVXPY solver name.

verbose : bool, default False : Forward solver verbosity.

solver_args : dict, default {} : Solver-specific options. Values supplied here override DiffAPQP defaults key by key. See Solvers for the configured profiles.

reduced_kkt : bool, default False : Use the reduced active-set KKT adjoint instead of the full complementarity KKT adjoint.

mode : {None, "lsqr", "spsolve", "minres"}, default None : Linear solver used by the backward adjoint.

mode_args : dict, default {} : Keyword arguments overriding the selected adjoint solver defaults.

See Adjoint configuration for more details.

Warm-start and update lifecycle

DiffAPQP's explicit sample-indexed cache and in-place update hooks cover OSQP, SCS, QPALM, and PROXQP when the custom CVXPY fork is installed. The distinction between sample-indexed warm starts and slot-indexed solver-data updates is explained in the forward-pass theory.

  1. On the first call with warm_start=True, cache entries are empty, so the solve initializes both the workspace and the per-sample iterates.
  2. A later call with the same idx_list reuses the iterates associated with each sample id.
  3. update=True reuses a batch slot's solver workspace after that slot has completed its first solve.
  4. Update-only calls do not require idx_list; idx_list is needed for the sample-indexed warm-start cache.

For other solvers, warm_start is passed through the standard CVXPY interface; idx_list is not used and update does not activate a DiffAPQP-specific data update path. See Installation for the custom fork.

Adjoint configuration

mode=None chooses a method from the KKT formulation:

See Solution-Map Differentiation for the full and reduced systems and their adjoint equations.

KKT formulation Default mode Default mode arguments
Full KKT (reduced_kkt=False) lsqr atol=1e-6, btol=1e-6, iter_lim=20000, show=False
Reduced KKT (reduced_kkt=True) minres rtol=1e-9

spsolve is available for either formulation and has no DiffAPQP-specific default arguments. minres is accepted only with reduced KKT. Using lsqr with reduced KKT is allowed but emits a warning because minres is normally preferred for that symmetric system.

When constructor tolerances are None, active-set tolerances are inferred from values explicitly supplied in solver_args. DiffAPQP checks eps_abs, tol_gap_abs, tol_feas, or tol_feas_abs for an absolute solver tolerance and eps_rel, tol_gap_rel, or tol_feas_rel for a relative tolerance. For either component \(\bullet\in\{\mathrm{abs},\mathrm{rel}\}\),

\[ \epsilon_\bullet = \max(10^{-4},\,10\epsilon_{\mathrm{solver},\bullet}) \]

when a matching solver tolerance is supplied, and \(\epsilon_\bullet=10^{-4}\) otherwise. Explicit constructor values take precedence. For a sample with canonical solution \(\tilde z^\star\), DiffAPQP identifies the active set

\[ \mathcal{A} = \left\{ j: \left|(G\tilde z^\star)_j-h_j(\hat y)\right| \le \max\left( \epsilon_{\mathrm{abs}}, \epsilon_{\mathrm{rel}}(1+|h_j(\hat y)|) \right) \right\}. \]

Only losses constructed from primal_dict are differentiated by SolMapLayer; derivatives with respect to returned dual variables are not supported.

Returns

primal_dict : dict[str, torch.Tensor] : Autograd-connected retrieved decisions \(\hat z^\star\), keyed by original CVXPY variable name. A variable with original shape shape is returned with shape (batch, *shape); scalar variables have shape (batch,).

dual_eq : numpy.ndarray, shape \((\mathrm{batch},n_{\mathrm{eq}})\) : Canonical equality duals \(\tilde\nu^\star\). This output is not autograd connected.

dual_ineq : numpy.ndarray, shape \((\mathrm{batch},n_{\mathrm{in}})\) : Canonical inequality duals \(\tilde\lambda^\star\). This output is not autograd connected.

raw_solutions : list : One solver-native result object per sample. Its type and fields depend on the selected CVXPY solver interface and should be treated as backend specific.

time_batch : dict : Batch wall-time measurements and per-sample pipeline timings:

{
    "canon_time": float,
    "solve_time": float,
    "sample_times": [
        {
            "worker_total_time": float,
            "get_problem_data_time": float,
            "solve_via_data_time": float,
            "unpack_time": float,
        },
        ...
    ],
}

sample_times contains one dictionary per sample in batch order:

Field Meaning
worker_total_time Wall time inside the solve worker, covering solve_via_data, result unpacking, status checks, and small Python overhead. It excludes get_problem_data_time.
get_problem_data_time Wall time for CVXPY's get_problem_data() call, which prepares the solver data and reduction-chain metadata for that sample during the serial preparation phase.
solve_via_data_time Wall time for CVXPY's solve_via_data() call, including solver-interface overhead and the backend's native optimization solve.
unpack_time Wall time for CVXPY's unpack_results() call, which maps the raw solver result back to the standardized CVXPY problem and populates primal, dual, status, and objective values.

canon_time is the serial wall time used to prepare CVXPY data for all samples. solve_time is the wall time of the serial or parallel solve phase, not the sum of sample times.

Errors and warnings

  • ValueError is raised for unsupported problems, negative constructor tolerances, missing parameters, invalid parameter types or shapes, inconsistent batch sizes, incompatible canonical variables, failed solver status, or mode="minres" with full KKT.
  • AssertionError is raised when a custom-path warm start is requested without idx_list.
  • optimal_inaccurate is accepted with a warning; other non-optimal statuses raise ValueError.
  • A UserWarning reports custom warm-start/update requests when the custom CVXPY fork is not detected.
  • A UserWarning is emitted for mode="lsqr" with reduced KKT.

Example

import cvxpy as cp
import torch

from diffapqp import SolMapLayer

x = cp.Variable(2, name="x")
q = cp.Parameter(2, name="q")
problem = cp.Problem(
    cp.Minimize(0.5 * cp.sum_squares(x) + q @ x),
    [x >= 0, x <= 10],
) # no standardization required

layer = SolMapLayer(problem, max_n_cores=1)
q_batch = torch.tensor(
    [[-1.0, -2.0], [0.5, -1.5]],
    dtype=torch.double,
    requires_grad=True,
)

primal_dict, *_ = layer(
    {"q": q_batch},
    solver="OSQP",
    reduced_kkt=True,
)
loss = primal_dict["x"].square().mean()
loss.backward()

In this example, q_batch supplies \(\hat y\), while primal_dict["x"] contains the corresponding retrieved decisions \(\hat z^\star\). The Python names follow the original CVXPY model and need not match the mathematical symbols.

Repeated warm-start/update calls:

sample_ids = [0, 1]

# First call initializes workspaces and sample caches.
layer(
    {"q": q_batch},
    idx_list=sample_ids,
    warm_start=True,
    update=True,
    solver="OSQP",
    reduced_kkt=True,
)

# Later calls with the same ids can reuse both.
primal_dict, *_ = layer(
    {"q": q_batch},
    idx_list=sample_ids,
    warm_start=True,
    update=True,
    solver="OSQP",
    reduced_kkt=True,
)

See also

References