QP Solver Backend: HiGHS#

This notebook demonstrates the HiGHS-based QP/MIQP solver backend in discopt. When HiGHS (highspy) is available, discopt automatically dispatches quadratic programs to HiGHS instead of the general-purpose NLP interior-point method, yielding significant performance improvements.

HiGHS [Huangfu and Hall, 2018] is a high-performance open-source solver for linear programming, mixed-integer programming, and quadratic programming. The discopt integration extracts the quadratic objective (the Hessian matrix \(Q\)) and linear constraints from the expression DAG and passes them directly to HiGHS via its Python API.

By the end of this notebook you will understand how discopt dispatches QP and MIQP problems to HiGHS, see the performance difference compared to the NLP path, and formulate a mixed-integer quadratic program solved natively by HiGHS.

import os

os.environ["JAX_PLATFORMS"] = "cpu"
os.environ["JAX_ENABLE_X64"] = "1"

import time

import discopt.modeling as dm
import numpy as np

1. QP Formulation and Solve via HiGHS#

We begin with a simple convex QP: minimize a quadratic objective subject to a linear constraint. When discopt detects that the objective is quadratic and all constraints are linear (with all continuous variables), it classifies the problem as a QP and dispatches to HiGHS automatically.

# Simple QP: min 0.5*x^2 + y^2  s.t. x + y >= 1, x,y >= 0
m = dm.Model("simple_qp")
x = m.continuous("x", lb=0, ub=10)
y = m.continuous("y", lb=0, ub=10)
m.minimize(0.5 * x**2 + y**2)
m.subject_to(x + y >= 1)

result = m.solve()
print(f"Status:    {result.status}")
print(f"Objective: {result.objective:.6f}")
print(f"x = {float(result.value(x)):.6f}")
print(f"y = {float(result.value(y)):.6f}")
print(f"Wall time: {result.wall_time:.4f}s")
Status:    optimal
Objective: 0.333333
x = 0.666667
y = 0.333333
Wall time: 0.0291s
# Verify the problem is classified as QP and dispatched to HiGHS
from discopt._jax.problem_classifier import ProblemClass, classify_problem

pc = classify_problem(m)
print(f"Problem class: {pc}")
assert pc == ProblemClass.QP, "Expected QP classification"
Problem class: ProblemClass.QP

2. Performance Comparison: HiGHS vs NLP Path#

To demonstrate the performance advantage of the HiGHS QP backend, we solve a portfolio optimization QP using both backends and compare wall-clock times. The HiGHS backend uses a specialized active-set QP solver, while the NLP path uses a general-purpose interior-point method with JAX autodiff for gradient and Hessian evaluation.

from discopt.solver import _solve_qp_jax


def build_portfolio_qp(n_assets, target_return=0.08, seed=42):
    """Build a Markowitz portfolio QP with n_assets."""
    rng = np.random.RandomState(seed)
    mu = rng.uniform(0.02, 0.15, n_assets)
    L = rng.randn(n_assets, n_assets) * 0.05
    Sigma = L @ L.T + 0.01 * np.eye(n_assets)

    m = dm.Model(f"portfolio_{n_assets}")
    w = m.continuous("w", shape=(n_assets,), lb=0, ub=1)

    m.minimize(
        dm.sum(
            lambda i: dm.sum(
                lambda j: Sigma[i, j] * w[i] * w[j],
                over=range(n_assets),
            ),
            over=range(n_assets),
        )
    )
    m.subject_to(dm.sum(w) == 1, name="budget")
    m.subject_to(
        dm.sum(lambda i: mu[i] * w[i], over=range(n_assets)) >= target_return,
        name="min_return",
    )
    return m


# Solve via HiGHS (default dispatch for QP)
m_highs = build_portfolio_qp(20)
t0 = time.perf_counter()
result_highs = m_highs.solve()
time_highs = time.perf_counter() - t0

# Solve via JAX IPM (bypass HiGHS by using the internal JAX path)
m_jax = build_portfolio_qp(20)
t0 = time.perf_counter()
result_jax = _solve_qp_jax(m_jax, time.perf_counter())
time_jax = time.perf_counter() - t0

header = f"{'Backend':<12s} {'Time (s)':>10s} {'Objective':>12s} {'Status':>10s}"
print(header)
print("-" * 48)
print(
    f"{'HiGHS':<12s} {time_highs:10.4f} {result_highs.objective:12.6f} {result_highs.status:>10s}"
)
print(f"{'JAX IPM':<12s} {time_jax:10.4f} {result_jax.objective:12.6f} {result_jax.status:>10s}")

if time_jax > 0:
    speedup = time_jax / time_highs
    print(f"\nHiGHS is {speedup:.1f}x faster than JAX IPM.")
Backend        Time (s)    Objective     Status
------------------------------------------------
HiGHS            0.0300     0.000987    optimal
JAX IPM          0.1756     0.000987    optimal

HiGHS is 5.8x faster than JAX IPM.

The HiGHS backend avoids JAX tracing overhead and uses a compiled C++ solver optimized for QP structure. For small to medium QPs, this overhead reduction is the dominant factor. For larger problems, the algorithmic advantage of HiGHS’s active-set method (which exploits sparsity in the KKT system) becomes increasingly important.

3. MIQP Example: Cardinality-Constrained Portfolio#

Mixed-integer quadratic programs (MIQPs) extend QPs with integer or binary variables. A common application is portfolio optimization with a cardinality constraint: limit the number of assets held to at most \(k\). This requires binary indicator variables \(z_i \in \{0, 1\}\) where \(z_i = 1\) means asset \(i\) is included in the portfolio.

When discopt detects an MIQP, it dispatches to HiGHS which solves it via branch-and-bound with QP relaxations at each node.

# Cardinality-constrained portfolio: hold at most k=3 of 8 assets
n_assets = 8
k_max = 3
np.random.seed(42)

mu = np.array([0.12, 0.10, 0.07, 0.03, 0.15, 0.09, 0.11, 0.06])
L = np.random.randn(n_assets, n_assets) * 0.04
Sigma = L @ L.T + 0.005 * np.eye(n_assets)
target_return = 0.08

m = dm.Model("cardinality_portfolio")
w = m.continuous("w", shape=(n_assets,), lb=0, ub=1)
z = m.binary("z", shape=(n_assets,))  # z[i]=1 if asset i is held

# Minimize portfolio variance
m.minimize(
    dm.sum(
        lambda i: dm.sum(
            lambda j: Sigma[i, j] * w[i] * w[j],
            over=range(n_assets),
        ),
        over=range(n_assets),
    )
)

# Budget constraint
m.subject_to(dm.sum(w) == 1, name="budget")

# Minimum return
m.subject_to(
    dm.sum(lambda i: mu[i] * w[i], over=range(n_assets)) >= target_return,
    name="min_return",
)

# Linking: w[i] <= z[i] (can only invest if asset is selected)
for i in range(n_assets):
    m.subject_to(w[i] <= z[i], name=f"link_{i}")

# Cardinality: at most k assets
m.subject_to(dm.sum(z) <= k_max, name="cardinality")

# Verify MIQP classification
pc = classify_problem(m)
print(f"Problem class: {pc}")

result = m.solve()
print(f"\nStatus:    {result.status}")
print(f"Variance:  {result.objective:.6f}")
print(f"Wall time: {result.wall_time:.4f}s")

w_vals = result.value(w)
z_vals = result.value(z)
print(f"\nSelected assets (z=1): {np.where(z_vals > 0.5)[0]}")
print(f"Weights: {np.round(w_vals, 4)}")
print(f"Return:  {np.dot(mu, w_vals):.4f}")
print(f"Assets held: {int(np.sum(z_vals > 0.5))} (max {k_max})")
Problem class: ProblemClass.MIQP
Status:    optimal
Variance:  0.002238
Wall time: 1.5932s

Selected assets (z=1): [0 2 5]
Weights: [0.3107 0.     0.3579 0.     0.     0.3315 0.     0.    ]
Return:  0.0922
Assets held: 3 (max 3)

4. How the Dispatch Works#

The solver dispatch logic in discopt follows this sequence for QP/MIQP problems.

The problem classifier walks the Rust expression IR to check whether the objective is at most quadratic and all constraints are linear. Based on this classification and the presence of integer variables, the solver dispatch selects the appropriate backend:

  • QP (continuous, quadratic objective, linear constraints): HiGHS QP solver via passHessian(). Falls back to the JAX QP IPM if HiGHS is unavailable.

  • MIQP (integer/binary variables, quadratic objective, linear constraints): HiGHS MIQP solver (branch-and-bound with QP relaxations). Falls back to discopt’s own B&B with JAX QP relaxations if HiGHS is unavailable.

The algebraic coefficient extraction (extract_qp_data) walks the expression DAG to directly read off the \(Q\) matrix, linear cost \(c\), and constraint matrix \(A\) without any automatic differentiation. This makes the data extraction essentially free compared to the solve time.

5. Summary#

The HiGHS QP backend provides a significant speedup for quadratic and mixed-integer quadratic programs by leveraging a compiled, specialized solver instead of the general NLP interior-point path. The dispatch is transparent: users simply call model.solve() and discopt selects the best available backend automatically. When HiGHS is not installed, the solver gracefully falls back to the JAX-based QP IPM, so all code remains portable.