Batching, JIT, and Grad with Problem.solve_jax()¶
Problem exposes two solve entry points. solve() is the familiar
Python-loop driver — real-time prints, wall-clock time_limit, continuous
mode, populated per-iteration history. solve_jax() is its JAX-pure
sibling: it drives the same fused iteration_fn body inside a
lax.while_loop and returns an OptimizationResults
(openscvx/algorithms/optimization_results.py) pytree, so it composes with
jax.vmap, jax.jit, and jax.grad.
problem = ox.Problem(..., solver={"backend": "qpax"})
problem.initialize()
# Familiar interactive solve — unchanged: prints, history, time limit.
result = problem.solve()
# JAX-pure single solve — silent, no per-iteration history, batchable.
result = problem.solve_jax()
# Batched — standard jax.vmap, no library-specific API.
batched = jax.vmap(problem.solve_jax, in_axes=(0, 0, None))(x_initial_stack, x_final_stack, params)
# JIT-compile once, solve forever (e.g. MPC inner loop).
fast_solve = jax.jit(problem.solve_jax)
A worked example lives at examples/abstract/brachistochrone_batched.py
— four brachistochrone problems with shifted starting x-coordinates,
solved in parallel via jax.vmap(problem.solve_jax).
When to choose solve() vs. solve_jax()¶
| Use case | Entry point |
|---|---|
| Interactive use, real-time prints, plotting per iteration | solve() |
Wall-clock time_limit or continuous=True |
solve() |
| Per-iteration trajectories / weights / diagnostics | solve() |
| Batched solve over many initial conditions or parameters | solve_jax() |
| JIT-compile once, solve forever (MPC inner loop, scenario sweeps) | solve_jax() |
jax.grad through the solver (best-effort, untested) |
solve_jax() |
The split exists to make user intent explicit — a single dispatched
solve() would have to infer from arguments which path the caller wants,
and silent routing failures (like continuous=True quietly skipping the
Python loop) are exactly the failure class the two-method design exists to
prevent. See plans/jax-pure-solve.md's Decision Log for the longer
discussion.
Batched, disk-cached solves with solve_batched()¶
jax.vmap(solve_jax) and solve_batched() both run B SCP solves over
stacked boundary conditions; they differ in who owns the batch axis, and
that one difference is the whole point.
jax.vmap(solve_jax)— the caller owns the axis. Maximally idiomatic, composes withgrad/scan, but in-process JIT only:jax.exporthas no outervmaprule (call_exportedcannot be batched), so an exported artifact can never be re-vmapped, and every fresh process re-traces and re-compiles the entire loop.solve_batched(x_initial=x0_batch)— the library owns the axis. The vmap is applied inside the method, before any export, so the whole loop lowers to ordinary batched XLA ops and serializes cleanly. Undersave_compiled=Trueit is written to the solver cache on the first run and deserialized on later processes — skipping the XLA compile thatjax.vmap(solve_jax)pays on every launch.
problem = ox.Problem(..., solver={"backend": "qpax"})
problem.settings.sim.save_compiled = True
problem.initialize()
# First process: traces, exports, writes <cache>/compiled_solve_batched_<hash>.jax
# Later processes: deserialize-and-.call, no compile.
batched = problem.solve_batched(x_initial=x0_batch, parameters=params)
# batched.x.shape == (B, N, n_states) — same pytree jax.vmap(solve_jax) returns.
One rule: add a leading batch axis to whatever varies¶
solve_batched takes exactly solve_jax's arguments. Every batchable input
has a declared (unbatched) shape the library knows — (n_x,) for the
boundary pins, (N, n_x) / (N, n_u) for the trajectory guesses, the
parameter's stored shape for each parameter, and () for the SCP
hyperparameters. An argument that matches its declared shape is shared by
every batch element; one with exactly one extra leading axis is batched
along it. Rank against the declared shape decides — never the leading-axis
value — so a shared (B, n) matrix parameter is never misread as batched.
(B in these shape patterns is plain notation for the batch size, not
syntax: it is never passed anywhere — like jax.vmap, the library reads it
off the leading axis of whatever you batched and cross-checks that all
batched inputs agree.)
results = problem.solve_batched(
x_initial=x0_batch, # (B, n_x): rank = declared+1 -> batched
parameters={
"gate_center": centers, # (B, 3) vs declared (3,) -> batched
"gate_radius": 0.5, # declared shape -> shared
},
)
For the rare entry the rank rule cannot express — e.g. batching a declared
rank-1 parameter element-wise — pass an explicit in_axes in jax.vmap's
vocabulary, including its prefix semantics (0 = batched along the leading
axis, None = shared; a prefix applies to everything below it; partial specs
fall back to the rank rule):
results = problem.solve_batched(
parameters={"weights": w}, # (B,) is also w's declared shape...
in_axes={"parameters": {"weights": 0}}, # ...so say "batched" explicitly
)
results = problem.solve_batched(parameters=params, in_axes={"parameters": 0})
# prefix: every passed parameter is batched, exactly like jax.vmap's
# pytree-prefix in_axes; a bare in_axes=0 batches every passed input.
One deliberate divergence from jax.vmap: the default is "infer by rank",
not 0-everything — an all-shared batched solve has no batch axis to find,
and inference is what makes mixed shared/batched dicts ergonomic.
Hyperparameter sweeps: the algorithm dict¶
Solve-time mirrors construction-time: the Problem constructor configures
the SCP algorithm with a dict (algorithm={"ep_tr": 1e-5, "lam_prox": 1.0}),
and the solve methods take the same box with the same names as per-solve,
batchable inputs. Every AlgorithmState field is a runtime input riding the
state pytree, and the algorithm dict reaches any of them by name —
convergence thresholds (ep_tr / ep_vb / ep_vc), penalty weights
(lam_prox / lam_vc / lam_cost), the seed iterate, autotuner
hyperparameters. There is no whitelist: the field declaration is the
registration, and the AlgorithmState attribute docs are the reference.
(max_iters survives as a named kwarg — sugar for algorithm={"k_max":
...}; passing both raises, as does an entry that shadows any other named
kwarg's field. Construction-only keys of the constructor dict — autotuner,
t_max — raise pointing back at the constructor.)
- On
solve_jax, each value matches the field's shape, or is a scalar broadcast to it:solve_jax(algorithm={"ep_tr": 1e-6, "lam_prox": 2.0})retraces nothing. - On
solve_batched, each value follows the rank rule against the field's shape, plus two fill forms for array-shaped fields: a scalar (shared) or a(B,)vector (one scalar per element, broadcast to the field shape). Exact shapes win when both parse;in_axes={"algorithm": {name: 0}}forces the per-element reading. All against one cached artifact:
B = 8 # batch size. Just an int for building the arrays below — there is no
# B argument; solve_batched infers it from the leading-axis lengths.
sweep = problem.solve_batched(algorithm={"ep_tr": jnp.logspace(-6, -3, B)})
weights = problem.solve_batched(algorithm={"lam_prox": jnp.array([0.5, 1.0, 4.0])})
budgets = problem.solve_batched(max_iters=jnp.array([5, 10, 20])) # per-element caps
The batch runs until its slowest element converges or exhausts its own cap;
finished elements are frozen, not re-iterated, so each element matches the
corresponding single solve_jax run.
Every built-in autotuner knob batches for free. All numeric knobs of the
built-in autotuners are declared hyperparameters — the trust-region factors
(gamma_1 / gamma_2), the acceptance thresholds (eta_0 / eta_1 /
eta_2), the weight clips, the cost-relaxation schedule — so any of them is a
solve_jax override and a solve_batched sweep target by name:
# sweep the trust-region growth factor across a batch, one cached artifact
results = problem.solve_batched(algorithm={"gamma_1": jnp.linspace(1.5, 3.0, B)})
New autotuners batch the same way. A custom autotuner's tunable knobs
follow the same derivation: declare them as fields on a HyperParams subclass
— bare annotations, no decorator; subclassing applies the frozen-dataclass
transform and the JAX pytree registration, and the annotation picks the dtype
(int gets the iteration counter's, float the problem's) — assign an
instance to self.hyper, and read state.hyper.<field> inside
update_weights (see the AutotuningBase docstring). The declaration is the
registration — each field immediately works as a solve_jax override, a
solve_batched sweep target, and a runtime input of the exported artifact,
with zero edits anywhere else:
from openscvx.algorithms import AutotuningBase, HyperParams
class MyHyper(HyperParams):
ramp: float = 2.0
class MyAutotuner(AutotuningBase):
def __init__(self, ramp: float = 2.0):
self.hyper = MyHyper(ramp=ramp)
def update_weights(self, state, ...):
...state.hyper.ramp...
results = problem.solve_batched(algorithm={"ramp": jnp.linspace(1.5, 3.0, B)})
Pick solve_batched when cross-process cold-start dominates — short-lived
processes (CI sweeps, notebook restarts, deployed workers) that otherwise pay
the compile every launch. Pick jax.vmap(solve_jax) for everything in-program,
where you own the transforms and want grad / scan composition. With
save_compiled=False, solve_batched is just an in-process batched wrapper and
the two produce identical results.
The exported artifact is closed and deployable, not composable: it is the
call_exported-has-no-outer-vmap-rule wall again. Use solve_jax for
user-driven jax.vmap / jax.grad; solve_batched for a cached batched solve.
CVXPy cannot be exported¶
Export requires every op in the loop — the backend solve included — to lower to
serializable XLA. CVXPy's iteration_callback is a jax.pure_callback, and
jax.export cannot serialize host callbacks. So PTRSolver.exportable is
False for CVXPy (and True for QPAX / Moreau), and
solve_batched(save_compiled=True) on the CVXPy backend raises a teaching
error pointing at QPAX / Moreau rather than silently degrading to an uncached
in-process solve. With save_compiled=False, CVXPy still runs B sequential
in-process solves.
Cache invalidation is correctness, not just freshness¶
The exported loop closes over far more than the symbolic problem does. Caching
on the symbolic hash alone would silently deserialize a stale artifact when
any of that extra state changes — a wrong-answer bug, not a cache miss. So
get_solve_batched_cache_path (openscvx/utils/caching.py) extends the
symbolic hash with everything additionally baked into the loop, each runtime
object contributing through its own _hash_into (the same protocol the symbolic
layer uses):
- the convex backend class + its
solver_args, - the algorithm + autotuner + initial weights,
- the discretizer scheme (class + hold type + ODE solver),
- the state/control scaling matrices
inv_S_x/inv_S_u, - the fixed batch size
B(the artifact is exported at oneB), - the per-parameter shared/batched split (baked into the
jax.vmapprogram), and - the JAX version (exported artifacts are not guaranteed to survive an incompatible jax bump).
Change any of these — backend, a penalty weight, a state bound, which
parameters are batched — and you get a different cache path, so a stale
artifact is never reused. Everything the algorithm dict can reach — convergence
thresholds, max_iters, weights, autotuner hyperparameters — is deliberately
not in the key: those are runtime inputs on the state pytree, so one
artifact serves every setting of them.
examples/abstract/brachistochrone_batched.py shows both paths side by side;
re-running it is itself the cross-process demo.
What solve_jax() returns¶
solve_jax() returns the same OptimizationResults type as solve(),
but built via
OptimizationResults.from_final_state(state, problem=...) instead of
from_history(history, final_state, ...). The differences:
- Per-iteration history is empty.
X = [state.x]andU = [state.u]are single-element lists soresult.x/result.ucontinue to return the final iterate; every*_historyfield is[]. List growth doesn't fit insidelax.while_loop. If you need per-iteration trajectories, usesolve()or wrapsolve_jax()inlax.scanmanually. - Post-process fields stay
None.t_full,x_full,u_full,cost,ctcs_violationare populated only bypost_process(). They're outside the JAX pytree's children surface, so a batchedsolve_jaxresult doesn't force every consumer to handleNoneleaves. If you want to post-process, callpost_process()per batch element after the batched solve. convergedis ajnp.bool_under the JAX-pure path (a(B,)array under vmap), not a Pythonboollikesolve()returns. Most uses (if result.converged: ...) work either way.
solve_jax() arguments¶
problem.solve_jax(
x_initial=None, # boundary-condition pin (full unified state vector,
# ``jnp.nan`` at non-Fix entries — see
# ``AlgorithmState.from_settings``); falls back
# to the default from settings
x_final=None, # terminal pin, same conventions
parameters=None, # parameters dict for this solve; falls back to
# ``self._parameters``
*,
x_guess=None, # (N, n_x) state trajectory warm-start
u_guess=None, # (N, n_u) control trajectory warm-start
max_iters=None, # SCP iteration cap; sugar for algorithm={"k_max": ...};
# falls back to the configured k_max
algorithm=None, # any AlgorithmState field by name (ep_tr, lam_prox,
# ...) — the constructor's algorithm-dict names;
# values match the field shape or fill it from a
# scalar. Runtime inputs on the state pytree — no
# value forces a retrace
)
Positional kwargs (rather than a single inputs pytree) keep
jax.vmap(problem.solve_jax, in_axes=(0, 0, None)) ergonomic;
multi-argument gradient uses jax.grad(..., argnums=(0, 1)).
Caveats¶
CVXPy under vmap is sequential¶
The CVXPy backend's iteration_callback
(openscvx/solvers/cvxpy_ptr_solver.py) host-calls CVXPy through
jax.pure_callback with vmap_method="sequential". Host
CVXPy is not thread-safe, so a jax.vmap(problem.solve_jax) over the
CVXPy backend runs B sequential CVXPy solves. The QPAX and Moreau
backends are pure JAX end-to-end and run in parallel under vmap.
Moreau warm-start is bypassed¶
The Moreau backend's _warm_start carry is host-side mutable state that
lax.while_loop doesn't thread. Both solve() and solve_jax() cold-start
the inner Moreau solve every SCP iteration (see the Moreau module
docstring and plans/jax-pure-solve.md's Decision Log 2026-05-27).
Restoring warm-start to the SCP loop requires threading (x, z, s)
through an AlgorithmState.moreau_carry field — a Future Extension.
jax.grad is best-effort, untested¶
The QPAX backend's solve_qp is not differentiable (its convergence flag
costs reverse-mode autodiff). The CVXPy backend's pure_callback is
non-differentiable by default. End-to-end gradient validation through
solve_jax is a follow-up — jax.grad(loss_fn)(x0) will run without
erroring under QPAX (solve_qp_primal would be custom_vjp, but the
backend currently uses solve_qp), but the gradient's correctness is not
yet pinned down by a test against finite differences.
Convergence under vmap with mixed-rate elements¶
lax.while_loop continues while any batch element still needs
iterations — converged elements would otherwise keep receiving body calls
while their peers iterate. make_solve_loop's body selects the unchanged
state for converged elements (jax.tree.map(jnp.where, ...)), so a
batched solve agrees with the single-problem solve_jax on each element.
The cost is per-iteration work × slowest-element iteration count.