ripopt: Rust Interior Point Method#
This tutorial introduces ripopt, a native Rust implementation of an interior-point method (IPM) for nonlinear programming. ripopt is compiled to a shared library via maturin and exposed to Python through PyO3 bindings.
By the end of this notebook you will be able to:
Solve NLPs using the
nlp_solver="ripopt"backend.Understand the architecture: Rust solver with Python evaluation callbacks.
Configure ripopt solver options.
Compare ripopt performance against the pure-JAX IPM and Ipopt backends.
Use ripopt as the NLP subsolver inside Branch-and-Bound for MINLPs.
When to choose ripopt:
Deployment without C dependencies. Unlike Ipopt (which requires BLAS, LAPACK, and optionally HSL), ripopt is a single compiled
.sowith no external C/Fortran dependencies.Single-threaded performance. Rust’s zero-cost abstractions and lack of garbage collection yield competitive single-threaded speed.
No Python GIL concerns. The Rust solver releases the GIL during computation, making it safe to use in multi-threaded Python applications.
References: The IPM algorithm is based on the primal-dual interior-point framework described in [Nocedal and Wright, 2006], Chapter 19.
import os
os.environ["JAX_PLATFORMS"] = "cpu"
os.environ["JAX_ENABLE_X64"] = "1"
import time
import discopt.modeling as dm
import numpy as np
Architecture#
ripopt follows a callback-based design common to NLP solvers like Ipopt [Wächter and Biegler, 2006]. The architecture has four layers:
Python (discopt.modeling)
| formulates Model, builds NLPEvaluator
v
Python (discopt.solvers.nlp_ripopt)
| maps evaluator callbacks to ripopt API
v
PyO3 bindings (discopt._rust.solve_ripopt)
| wraps Python evaluator as ripopt::NlpProblem
v
Rust solver (ripopt crate)
| primal-dual IPM with line search
| calls back into Python for f(x), grad(x), Hessian(x)
Key points:
The Rust solver owns the IPM iteration loop, KKT system assembly, and line search logic.
Objective, gradient, constraint, Jacobian, and Hessian evaluations are performed by the Python
NLPEvaluatorvia GIL-acquiring callbacks.No external C libraries (BLAS, LAPACK, HSL) are required — all linear algebra is implemented in Rust.
Variable and constraint bounds are passed once at initialization; the Rust solver caches them as owned
Vec<f64>data.
Simple NLP Examples#
Let us start with two small problems to verify that ripopt produces correct results.
# Unconstrained quadratic: min (x - 3)^2 + (y + 1)^2
# Known optimal: x* = 3, y* = -1, f* = 0
m = dm.Model("unconstrained_quad")
x = m.continuous("x", lb=-10, ub=10)
y = m.continuous("y", lb=-10, ub=10)
m.minimize((x - 3) ** 2 + (y + 1) ** 2)
result = m.solve(nlp_solver="ripopt")
print(f"Status: {result.status}")
print(f"Objective: {result.objective:.6f}")
print(f"x = {result.x['x']:.6f} (expected 3.0)")
print(f"y = {result.x['y']:.6f} (expected -1.0)")
print(f"Time: {result.wall_time:.4f}s")
Status: optimal
Objective: 0.000000
x = 3.000000 (expected 3.0)
y = -1.000000 (expected -1.0)
Time: 0.4493s
# Constrained: min x^2 + y^2 s.t. x + y >= 1
# Known optimal: x* = y* = 0.5, f* = 0.5
m2 = dm.Model("constrained_simple")
x = m2.continuous("x", lb=-5, ub=5)
y = m2.continuous("y", lb=-5, ub=5)
m2.minimize(x**2 + y**2)
m2.subject_to(x + y >= 1.0, name="sum_lb")
result2 = m2.solve(nlp_solver="ripopt")
print(f"Status: {result2.status}")
print(f"Objective: {result2.objective:.6f} (expected 0.5)")
print(f"x = {result2.x['x']:.6f} (expected 0.5)")
print(f"y = {result2.x['y']:.6f} (expected 0.5)")
Status: optimal
Objective: 0.500000 (expected 0.5)
x = 0.500000 (expected 0.5)
y = 0.500000 (expected 0.5)
Constrained Rosenbrock#
The Rosenbrock function is a classic nonlinear test problem with a narrow curved valley. We add a disk constraint to make it more challenging:
The unconstrained optimum is \((1, 1)\) with \(f^* = 0\). The disk constraint \(x^2 + y^2 \le 2\) is inactive at that point, so the constrained optimum should also be \((1, 1)\).
mr = dm.Model("rosenbrock")
x = mr.continuous("x", lb=-2, ub=2)
y = mr.continuous("y", lb=-2, ub=2)
mr.minimize((1 - x) ** 2 + 100 * (y - x**2) ** 2)
mr.subject_to(x**2 + y**2 <= 2.0, name="disk")
rr = mr.solve(nlp_solver="ripopt")
print(f"Status: {rr.status}")
print(f"Objective: {rr.objective:.8f} (expected ~0)")
print(f"x = {rr.x['x']:.6f} (expected 1.0)")
print(f"y = {rr.x['y']:.6f} (expected 1.0)")
print(f"Disk constraint: x^2 + y^2 = {rr.x['x'] ** 2 + rr.x['y'] ** 2:.4f} <= 2.0")
Status: optimal
Objective: 0.00000000 (expected ~0)
x = 0.999998 (expected 1.0)
y = 0.999996 (expected 1.0)
Disk constraint: x^2 + y^2 = 2.0000 <= 2.0
Configuration#
ripopt inherits solver options from the solve() call. The most
commonly used options are:
Option |
Default |
Description |
|---|---|---|
|
300 |
Maximum wall-clock seconds |
|
3000 |
Maximum IPM iterations |
|
1e-8 |
Optimality tolerance |
|
0 |
Verbosity (0 = silent, 1+ = verbose) |
These are passed through m.solve() keyword arguments.
# Solve with a time limit and iteration cap
mc = dm.Model("config_demo")
x = mc.continuous("x", lb=-10, ub=10)
y = mc.continuous("y", lb=-10, ub=10)
mc.minimize((x - 2) ** 2 + (y - 3) ** 2)
rc = mc.solve(nlp_solver="ripopt", time_limit=10)
print(f"Status: {rc.status}, Objective: {rc.objective:.6f}")
print(f"x = {rc.x['x']:.4f}, y = {rc.x['y']:.4f}")
print(f"Wall time: {rc.wall_time:.4f}s (limit was 10s)")
Status: optimal, Objective: 0.000000
x = 2.0000, y = 3.0000
Wall time: 0.0139s (limit was 10s)
Performance Comparison#
We compare three NLP solver backends on five small problems:
"ipm"— pure-JAX interior-point method (Mehrotra predictor-corrector)"ripopt"— Rust IPM via PyO3"ipopt"— Ipopt via cyipopt (requires BLAS/LAPACK)
Each problem is solved once per backend. Note that the first JAX IPM call includes JIT compilation overhead; subsequent calls of the same size reuse the compiled kernel.
The interior-point framework is described in [Wright, 1997].
def make_unconstrained_quad(n=2):
"""min sum_i (x_i - i)^2"""
m = dm.Model(f"quad_{n}")
x = m.continuous("x", shape=(n,), lb=-10, ub=10)
m.minimize(dm.sum(lambda i: (x[i] - i) ** 2, over=range(n)))
return m
def make_constrained(n=5):
"""min sum x_i^2 s.t. sum x_i >= n/2"""
m = dm.Model(f"constr_{n}")
x = m.continuous("x", shape=(n,), lb=-5, ub=5)
m.minimize(dm.sum(lambda i: x[i] ** 2, over=range(n)))
m.subject_to(dm.sum(x) >= n / 2.0)
return m
def make_rosenbrock():
"""min (1-x)^2 + 100*(y-x^2)^2"""
m = dm.Model("rosenbrock")
x = m.continuous("x", lb=-5, ub=5)
y = m.continuous("y", lb=-5, ub=5)
m.minimize((1 - x) ** 2 + 100 * (y - x**2) ** 2)
return m
def make_sum_of_squares(n=10):
"""min sum_i (x_i - sin(i))^2 s.t. sum x_i <= n"""
m = dm.Model(f"sos_{n}")
x = m.continuous("x", shape=(n,), lb=-5, ub=5)
targets = [float(np.sin(i)) for i in range(n)]
m.minimize(dm.sum(lambda i: (x[i] - targets[i]) ** 2, over=range(n)))
m.subject_to(dm.sum(x) <= float(n))
return m
def make_portfolio(n=8):
"""min w' Sigma w s.t. sum w = 1, mu'w >= 0.08"""
np.random.seed(42)
L = np.random.randn(n, n) * 0.05
Sigma = L @ L.T + 0.01 * np.eye(n)
mu = np.linspace(0.03, 0.15, n)
m = dm.Model(f"portfolio_{n}")
w = m.continuous("w", shape=(n,), lb=0, ub=1)
m.minimize(
dm.sum(
lambda i: dm.sum(
lambda j: Sigma[i, j] * w[i] * w[j],
over=range(n),
),
over=range(n),
)
)
m.subject_to(dm.sum(w) == 1.0)
m.subject_to(dm.sum(lambda i: mu[i] * w[i], over=range(n)) >= 0.08)
return m
problems = [
("Quadratic (n=2)", make_unconstrained_quad(2)),
("Constrained (n=5)", make_constrained(5)),
("Rosenbrock (n=2)", make_rosenbrock()),
("Sum-of-squares (n=10)", make_sum_of_squares(10)),
("Portfolio (n=8)", make_portfolio(8)),
]
print(f"Prepared {len(problems)} test problems.")
Prepared 5 test problems.
backends = ["ipm", "ripopt", "ipopt"]
results_table = []
for name, model in problems:
row = {"Problem": name}
for backend in backends:
try:
t0 = time.perf_counter()
r = model.solve(nlp_solver=backend)
elapsed = time.perf_counter() - t0
row[f"{backend}_time"] = f"{elapsed:.4f}s"
row[f"{backend}_obj"] = f"{r.objective:.6f}"
row[f"{backend}_status"] = r.status
except Exception as e:
row[f"{backend}_time"] = "error"
row[f"{backend}_obj"] = str(e)[:30]
row[f"{backend}_status"] = "error"
results_table.append(row)
# Display results
header = f"{'Problem':<25s} | {'ipm':>10s} | {'ripopt':>10s} | {'ipopt':>10s}"
print(header)
print("-" * len(header))
for row in results_table:
print(
f"{row['Problem']:<25s} | "
f"{row['ipm_time']:>10s} | "
f"{row['ripopt_time']:>10s} | "
f"{row['ipopt_time']:>10s}"
)
******************************************************************************
This program contains Ipopt, a library for large-scale nonlinear optimization.
Ipopt is released as open source code under the Eclipse Public License (EPL).
For more information visit https://github.com/coin-or/Ipopt
******************************************************************************
Problem | ipm | ripopt | ipopt
----------------------------------------------------------------
Quadratic (n=2) | 0.0129s | 0.0117s | 0.0120s
Constrained (n=5) | 0.0248s | 0.0007s | 0.0005s
Rosenbrock (n=2) | 0.0708s | 0.0013s | 0.0039s
Sum-of-squares (n=10) | 0.8051s | 0.0568s | 0.0584s
Portfolio (n=8) | 0.0274s | 0.0011s | 0.0009s
# Verify all backends agree on objective values
print(f"{'Problem':<25s} | {'ipm obj':>12s} | {'ripopt obj':>12s} | {'ipopt obj':>12s}")
print("-" * 70)
for row in results_table:
print(
f"{row['Problem']:<25s} | "
f"{row['ipm_obj']:>12s} | "
f"{row['ripopt_obj']:>12s} | "
f"{row['ipopt_obj']:>12s}"
)
Problem | ipm obj | ripopt obj | ipopt obj
----------------------------------------------------------------------
Quadratic (n=2) | 0.000000 | 0.000000 | 0.000000
Constrained (n=5) | 1.250000 | 1.250000 | 1.250000
Rosenbrock (n=2) | 0.000000 | 0.000000 | 0.000000
Sum-of-squares (n=10) | 0.000000 | 0.000000 | 0.000000
Portfolio (n=8) | 0.002064 | 0.002064 | 0.002064
Notes on timing:
The pure-JAX IPM (
"ipm") includes JIT compilation time on the first call. Subsequent solves of the same problem size are significantly faster.ripopt times include the overhead of PyO3 callback crossings (Python to Rust and back for each evaluation).
Ipopt typically has the most mature linear algebra (via HSL or MUMPS), which gives it an advantage on larger problems.
Using ripopt in Branch-and-Bound#
When solving a mixed-integer nonlinear program (MINLP), discopt uses
Branch-and-Bound (B&B). At each tree node, a continuous NLP relaxation
is solved. By setting nlp_solver="ripopt", every node relaxation is
solved with the Rust IPM instead of the default backend.
# Small MINLP: min x^2 + y s.t. x + y >= 2, y in {0, 1}
mm = dm.Model("minlp_ripopt")
x = mm.continuous("x", lb=0, ub=5)
y = mm.binary("y")
mm.minimize(x**2 + y)
mm.subject_to(x + y >= 2.0, name="coupling")
rm = mm.solve(nlp_solver="ripopt")
print(f"Status: {rm.status}")
print(f"Objective: {rm.objective:.6f}")
print(f"x = {rm.x['x']:.6f}")
print(f"y = {rm.x['y']:.0f}")
print(f"Node count: {rm.node_count}")
print(f"Gap: {rm.gap:.2e}")
print(f"Wall time: {rm.wall_time:.4f}s")
Status: optimal
Objective: 2.000000
x = 1.000000
y = 1
Node count: 1
Gap: 0.00e+00
Wall time: 0.1589s
# Slightly larger MINLP with 3 binary variables
ml = dm.Model("minlp_larger")
x = ml.continuous("x", shape=(3,), lb=0, ub=5)
z = ml.binary("z", shape=(3,))
# Nonlinear objective
ml.minimize(dm.sum(lambda i: (x[i] - float(i + 1)) ** 2, over=range(3)) + dm.sum(z))
# Linking constraints: x[i] <= 5 * z[i] (x active only if z selected)
for i in range(3):
ml.subject_to(x[i] <= 5.0 * z[i], name=f"link_{i}")
# Budget: at most 2 active
ml.subject_to(dm.sum(z) <= 2, name="budget")
rl = ml.solve(nlp_solver="ripopt")
print(f"Status: {rl.status}")
print(f"Objective: {rl.objective:.6f}")
print(f"x = {np.round(rl.x['x'], 4)}")
print(f"z = {np.round(rl.x['z'])}")
print(f"Node count: {rl.node_count}")
print(f"Wall time: {rl.wall_time:.4f}s")
Status: optimal
Objective: 3.000000
x = [0. 2. 3.]
z = [0. 1. 1.]
Node count: 9
Wall time: 1.4046s
When to Choose ripopt#
Scenario |
Recommended backend |
|---|---|
Deployment in containers / minimal dependencies |
ripopt — no C toolchain needed |
Batch solving many small NLPs |
ipm — JIT-compiled, amortized overhead |
Large-scale NLPs (n > 1000) |
ipopt — sparse linear algebra (HSL/MUMPS) |
Differentiable optimization / sensitivity |
ipm — native JAX autodiff |
Single-threaded, no-GIL performance |
ripopt — Rust execution |
General-purpose robustness |
ipopt — decades of development |
Drawbacks of ripopt:
Dense linear algebra only — no sparse KKT factorization yet, so performance degrades on large problems.
Evaluation overhead from PyO3 callbacks (each
f(x),grad(x), etc. crosses the Python/Rust boundary).Fewer algorithmic refinements than Ipopt (e.g., no adaptive barrier strategy, no inertia-free regularization).
Exercise#
Formulate a 10-variable constrained NLP and compare ripopt to the default JAX IPM backend. The problem:
where \(t_i = \sin(i)\).
n = 10
targets = [float(np.sin(i)) for i in range(n)]
# YOUR CODE HERE
# 1. Create a Model
# 2. Add continuous variables x with shape (n,), lb=-3, ub=3
# 3. Minimize sum_i (x[i] - targets[i])^4
# 4. Add equality constraint: sum(x) == 5
# 5. Solve with nlp_solver="ripopt" and time it
# 6. Solve with nlp_solver="ipm" and time it
# 7. Print both objectives and times
Solution#
Click to reveal
def build_quartic_model():
m = dm.Model("quartic")
x = m.continuous("x", shape=(n,), lb=-3, ub=3)
m.minimize(dm.sum(lambda i: (x[i] - targets[i]) ** 4, over=range(n)))
m.subject_to(dm.sum(x) == 5.0, name="sum_eq")
return m
# Solve with ripopt
m_rip = build_quartic_model()
t0 = time.perf_counter()
r_rip = m_rip.solve(nlp_solver="ripopt")
t_rip = time.perf_counter() - t0
# Solve with JAX IPM
m_ipm = build_quartic_model()
t0 = time.perf_counter()
r_ipm = m_ipm.solve(nlp_solver="ipm")
t_ipm = time.perf_counter() - t0
print(f"{'Backend':<10s} | {'Status':<10s} | {'Objective':>12s} | {'Time':>8s}")
print("-" * 48)
print(f"{'ripopt':<10s} | {r_rip.status:<10s} | {r_rip.objective:12.6f} | {t_rip:8.4f}s")
print(f"{'ipm':<10s} | {r_ipm.status:<10s} | {r_ipm.objective:12.6f} | {t_ipm:8.4f}s")
print(f"\nObjective difference: {abs(r_rip.objective - r_ipm.objective):.2e}")
Backend | Status | Objective | Time
------------------------------------------------
ripopt | optimal | 0.085947 | 0.1447s
ipm | optimal | 0.085947 | 0.1476s
Objective difference: 9.71e-17
Summary#
In this tutorial you learned to:
Select the ripopt backend with
m.solve(nlp_solver="ripopt").Understand the Rust IPM architecture: compiled
.so, PyO3 bindings, Python evaluation callbacks.Configure solver options (time limit, tolerance, verbosity).
Compare ripopt against the pure-JAX IPM and Ipopt backends on small-to-medium NLPs.
Use ripopt as the NLP subsolver inside Branch-and-Bound for MINLPs.
ripopt is the right choice when you need a dependency-free NLP solver for deployment, or when you want single-threaded Rust performance without the GIL. For large-scale problems or when sparse linear algebra is critical, Ipopt remains the stronger option [Wächter and Biegler, 2006]. For batch solving or differentiable optimization, the pure-JAX IPM is preferred [Nocedal and Wright, 2006].
See also:
IPM vs Ipopt comparison — detailed benchmarks
Batch IPM — vmapped IPM for batch solves
Advanced features — solver configuration guide