This package provides an easily-customizable interface for expressing mixed complementarity problems (MCPs) which are defined in terms of an arbitrary vector of parameters. MixedComplementarityProblems implements a reasonably high-performance interior point method for solving these problems, and integrates with ChainRulesCore and ForwardDiff to enable automatic differentiation of solutions with respect to problem parameters.
As of v0.2.2, MixedComplementarityProblems.jl implements CPU multithreading and GPU-parallelized solvers as well, enabled via KernelAbstractions.jl. Check out the benchmarking README here for more details.
Mixed complementarity problems (MCPs) are a class of mathematical program, and they arise in a wide variety of application problems. In particular, one way they can arise is via the KKT conditions of nonlinear programs and noncooperative games. This package provides a utility for constructing MCPs from (parameterized) games, cf. src/game.jl for further details. To see the connection between KKT conditions and MCPs, read the next section.
As discussed below, this package replicates functionality already available in ParametricMCPs. Our intention here is to provide an easily customizable and open-source solver with efficiency and reliability that is at least comparable with the PATH solver which ParametricMCPs uses under the hood (actually, it hooks into the interface to the PATH binaries which is provided by another wonderful package, PATHSolver). Hopefully, users will find it useful to modify the interior point solver provided in this package for their own application problems, use it for highly parallelized implementations (since it is in pure Julia), etc.
MixedComplementarityProblems is a registered package and can be installed with the standard Julia package manager as follows:
] add MixedComplementarityProblemsSuppose we have the following quadratic program:
min_x 0.5 xᵀ M x - θᵀ x
s.t. Ax - b ≥ 0.
The KKT conditions for this problem can be expressed as follows:
G(x, y; θ) = Mx - θ - Aᵀ y = 0
H(x, y; θ) = Ax - b ≥ 0
y ≥ 0
yᵀ H(x, y; θ) = 0,
where y is the Lagrange multiplier associated to the constraint Ax - b ≥ 0 in the original problem.
This is precisely a MCP, whose standard form is:
G(x, y; θ) = 0
0 ≤ y ⟂ H(x, y; θ) ≥ 0.
Now, we can encode this problem and solve it using MixedComplementarityProblems as follows:
using MixedComplementarityProblems
M = [2 1; 1 2]
A = [1 0; 0 1]
b = [1; 1]
θ = rand(2)
G(x, y; θ) = M * x - θ - A' * y
H(x, y; θ) = A * x - b
mcp = MixedComplementarityProblems.PrimalDualMCP(
G,
H;
unconstrained_dimension = size(M, 1),
constrained_dimension = length(b),
parameter_dimension = size(M, 1),
)
sol = MixedComplementarityProblems.solve(MixedComplementarityProblems.InteriorPoint(), mcp, θ)The solver can easily be warm-started from a given initial guess:
sol = MixedComplementarityProblems.solve(
MixedComplementarityProblems.InteriorPoint(),
mcp,
θ;
x₀ = # your initial guess
y₀ = # your **positive** initial guess
)Note that the initial guess for the src/solver.jl.
Finally, MixedComplementarityProblems integrates with ChainRulesCore and ForwardDiff so you can differentiate through the solver itself! For example, suppose we wanted to find the value of
min_{θ, x, y} f(x, y)
s.t. (x, y) solves MCP(θ).
We could do so by initializing with a particular value of
mcp = MixedComplementarityProblems.PrimalDualMCP(
G,
H;
unconstrained_dimension = size(M, 1),
constrained_dimension = length(b),
parameter_dimension = size(M, 1),
compute_sensitivities = true,
)
function f(θ)
sol = MixedComplementarityProblems.solve(MixedComplementarityProblems.InteriorPoint(), mcp, θ)
# Some example objective function that depends on `x` and `y`.
sum(sol.x .^ 2) + sum(sol.y .^ 2)
end
∇f = only(Zygote.gradient(f, θ))Many applications need to solve a whole batch of MCPs that share the same structure but
differ only in their parameters θ — e.g. sampling many initial conditions of a
trajectory game, or sweeping many instances of a parameterized program. For this,
MixedComplementarityProblems provides the BatchedInteriorPoint solver, which solves the
entire batch in a single call, parallelized across CPU threads or an NVIDIA GPU via a
single KernelAbstractions.jl
backend. The unbatched InteriorPoint solver above is unchanged; the batched solver is a
separate, additive entry point.
Two things differ from the unbatched setup:
- Build the MCP with
compute_kernel_evaluators = true. The batched solver needs these device-portable evaluators, so they are opt-in (they add to build time). - Stack your parameters into an
(nθ × B)matrixΘ, where columnbis the parameter vector of instanceb(Bis the batch size).
Start Julia with several threads — julia -t 4. On heterogeneous CPUs (e.g. Apple
silicon) use -t <#performance-cores>; oversubscribing past the physical performance
cores plateaus or regresses throughput. Then, reusing the QP from the quickstart above:
using MixedComplementarityProblems
M = [2 1; 1 2]
A = [1 0; 0 1]
b = [1; 1]
G(x, y; θ) = M * x - θ - A' * y
H(x, y; θ) = A * x - b
# Note the `compute_kernel_evaluators = true`, required by the batched solver.
mcp = MixedComplementarityProblems.PrimalDualMCP(
G,
H;
unconstrained_dimension = size(M, 1),
constrained_dimension = length(b),
parameter_dimension = size(M, 1),
compute_kernel_evaluators = true,
)
# Build a batch of B = 256 parameter vectors as an (nθ × B) matrix.
B = 256
Θ = rand(size(M, 1), B) # column b is instance b's parameter vector
sol = MixedComplementarityProblems.solve(
MixedComplementarityProblems.BatchedInteriorPoint(),
mcp,
Θ,
)The result is a named tuple (; status, x, y, s, kkt_error, ϵ, outer_iters, total_iters).
status is a length-B vector of :solved / :failed, and x, y, s are batched
(· × B) matrices — column b is instance b's solution. For example, the solution of
instance 5 is sol.x[:, 5], and count(==(:solved), sol.status) counts how many instances
converged.
The batched solver mirrors the unbatched schedule and accepts the same kinds of options,
per-instance where appropriate — e.g. tol, warm starts (X₀, Y₀, S₀, with Y₀, S₀
elementwise positive), and regularize_linear_solve (:identity default / :internal /
:none). Trajectory games (built via ParametricGame(...; compute_kernel_evaluators = true)) are supported and solve with the default :identity scheme.
Note on batches with hard/infeasible instances. A batch typically mixes easy, hard, and infeasible instances. Instances that neither converge nor diverge are declared
:failedand frozen aftermax_stall_rounds(default 5) consecutive non-converging outer rounds, so a few stragglers do not drag the whole batch to the iteration ceiling. Tunemax_stall_roundsagainst your problem distribution.
The GPU path uses the exact same solver call — you only (1) load the CUDA/cuDSS extension,
(2) move the parameter matrix to the device, and (3) pass device = CUDABackend(). The
GPU backend is provided by a package extension that activates when both CUDA and CUDSS
are loaded (both ship artifacts only for NVIDIA-capable platforms, so the base package
loads and runs unchanged on non-NVIDIA machines):
using MixedComplementarityProblems
using CUDA, CUDSS # loading both activates the GPU (cuDSS) backend extension
# `mcp` is built exactly as in the CPU example (compute_kernel_evaluators = true) — its
# symbolic structure is device-independent, so the same MCP works on CPU and GPU.
Θ_gpu = CuArray(Θ) # move the (nθ × B) parameter matrix to the GPU
sol = MixedComplementarityProblems.solve(
MixedComplementarityProblems.BatchedInteriorPoint(),
mcp,
Θ_gpu;
device = CUDA.CUDABackend(),
)The returned x, y, s live on the GPU (as CuArrays); bring them back with
Array(sol.x) if you need them on the host.
If you would rather write device-generic code (one code path that runs on either CPU or GPU), move parameters with
Adapt.adapt(device, Θ)instead ofCuArray— that is the pattern the benchmarks use.Adaptis not a dependency of this package, so you would need to add it to your own project (] add Adapt).
Performance status (as of
v0.2.3). GPU now beats a many-threaded CPU run by a consistent 2.5-2.8x on large-enough batched problems (e.g. the trajectory game oncehorizon ≳ 30), though CPU remains faster or roughly at parity for smaller per-instance problems — both handily beat PATH regardless. See the benchmarking README and PRs #54/#55 for the full breakdown and up-to-date numbers.
If you would like to get a better sense of the kinds of problems MixedComplementarityProblems was built for, check out the example in examples/lane_change.jl. This problem encodes a two-player game in which each player is driving a car and wishes to choose a trajectory that tracks a preferred lane center, maintains a desired speed, minimizes control actuation effort, and avoids collision with the other player. The problem is naturally expressed as a noncooperative game, and encoded as a mixed complementarity problem.
To run the example, activate the examples environment
] activate examplesand then, from within the examples directory run
include("TrajectoryExamples")
TrajectoryExamples.run_lane_change_example()This will generate a video animation and save it as sim_steps.mp4, and it should show two vehicles smoothly changing lanes and avoiding collisions. Once compiled, the entire example should run in a few seconds (including time to save everything).
This project inherits many key ideas from ParametricMCPs, which provides essentially identical functionality but which currently only supports the (closed-source, but otherwise excellent) PATH solver. Ultimately, this MixedComplementarityProblems will likely merge with ParametricMCPs to provide an identical frontend and allow users a flexible choice of backend solver. Currently, MixedComplementarityProblems replicates a substantially similar interface as that provided by ParametricMCPs, but there are some (potentially annoying) differences that users should take care to notice, e.g., in the function signature for solve(...).
If you are curious about other related software, consider checking out JuliaGameTheoreticPlanning.