Batched Solving
batch_solve(expr, lmo, X0) runs B independent Frank-Wolfe problems on an (n, B) matrix. Problems share one constraint set and step in lockstep; columns that have converged are masked out of subsequent updates.
CPU and GPU arrays are accepted. GPU dispatch flows through KernelAbstractions.get_backend(X0); Marguerite picks up whichever KA backend (CUDA, Metal, AMDGPU) is loaded. A ChainRulesCore.rrule differentiates through the solution mapping, and batch_bilevel_solve computes batched hypergradients.
Basic usage
A BatchedExpression carries per-problem callbacks for the objective and gradient. Each callback receives a single column view of the iterate, not the whole (n, B) matrix.
using Marguerite, LinearAlgebra
n, B = 10, 50
H = Matrix{Float64}(I, n, n)
C = randn(n, B)
# Per-column objective and gradient — `θ` argument unused for non-parametric problems.
f_per_col(x, _, b) = 0.5 * dot(x, H * x) + dot(view(C, :, b), x)
grad_per_col!(g, x, _, b) = (g .= H * x .+ view(C, :, b); g)
expr = BatchedExpression(f_per_col, grad_per_col!)
X0 = fill(1.0 / n, n, B)
lmo = ProbSimplex()
X, result = batch_solve(expr, lmo, X0)The return value is a BatchSolveResult supporting tuple unpacking. result is a BatchResult with per-problem objectives, gaps, convergence flags, and discard counts.
f_per_col and grad_per_col! must be column independent: the value and gradient at column b depend only on x_b (the b-th column of the iterate), not on the other columns. The implicit differentiation pipeline relies on this.
A per-column gradient is required. There is no auto-gradient fallback for batched mode; passing nothing for grad_per_col! is rejected at construction.
Configuration
Solver knobs are consolidated into a BatchSolveConfig:
cfg = BatchSolveConfig(
max_iters = 5000,
tol = 1e-6,
step_rule = AdaptiveStepSize(),
monotonic = true,
diff_lambda = 1e-4,
)
X, result = batch_solve(expr, lmo, X0; config=cfg)Per-call keyword arguments override matching fields of config:
X, result = batch_solve(expr, lmo, X0; config=cfg, max_iters=10000)GPU support
Pass a GPU matrix directly. Marguerite reads its backend via KernelAbstractions.get_backend(X0) and dispatches the LMO + gap kernel accordingly:
using CUDA
X0_gpu = CUDA.fill(1.0f0 / n, n, B)
X, result = batch_solve(expr, ProbSimplex(1.0f0), X0_gpu)Supported on device: ScalarBox, Box, ProbSimplex, Simplex.
CPU only: Knapsack, MaskedKnapsack, WeightedSimplex, Spectraplex, FunctionOracle. Calling these with a non-CPU backend raises ArgumentError.
AdaptiveStepSize is CPU only. Pass step_rule=MonotonicStepSize() (the default) for GPU.
CI exercises Metal only; CUDA and AMDGPU paths are best-effort. Bug reports welcome.
Adaptive step size
cfg = BatchSolveConfig(step_rule=AdaptiveStepSize(1.0))
X, result = batch_solve(expr, lmo, X0; config=cfg)Each problem maintains an independent Lipschitz estimate.
Parametric problems
For problems parameterized by shared $\theta$:
\[\min_{x_b \in C(\theta)} f_b(x_b, \theta) \quad b = 1, \ldots, B\]
f_param(x, θ, b) = 0.5 * dot(x, H * x) - dot(θ, x)
grad_param!(g, x, θ, b) = (g .= H * x .- θ; g)
expr = BatchedExpression(f_param, grad_param!)
θ = randn(n)
X, result = batch_solve(expr, lmo, X0, θ)If lmo is a ParametricOracle, the constraint set is materialized at $\theta$ automatically.
Implicit differentiation
The parametric batch_solve has a ChainRulesCore.rrule. The gradient $\partial\theta$ is the sum of per-problem KKT adjoint contributions ($\theta$ is shared across all problems):
using ChainRulesCore
(X_star, result), pb = rrule(batch_solve, expr, lmo, X0, θ)
dX = randn(size(X_star))
_, _, _, _, dθ = pb(dX)The pullback runs B independent scalar pullbacks on the host (no batched HVP, no batched factorization). The forward solve and the gradient evaluations stay batched on the device; the KKT adjoint solves serialize column by column. Per pullback work is O(B) adjoint solves on length-n columns.
For the full Jacobian $\partial X^* / \partial\theta$:
J, result = batch_solution_jacobian(expr, lmo, X0, θ)Bilevel optimization
batch_bilevel_solve computes hypergradients for batched bilevel problems:
\[\min_\theta \sum_{b=1}^B L_b(x^*_b(\theta)) \quad \text{where} \quad x^*_b(\theta) = \arg\min_{x \in C(\theta)} f_b(x, \theta)\]
Both outer and inner objectives use the BatchedExpression form:
outer_f(x, _, b) = sum(x .^ 2)
outer_grad!(g, x, _, b) = (g .= 2 .* x; g)
inner_f(x, θ, b) = 0.5 * dot(x, H * x) - dot(θ, x)
inner_grad!(g, x, θ, b) = (g .= H * x .- θ; g)
outer = BatchedExpression(outer_f, outer_grad!)
inner = BatchedExpression(inner_f, inner_grad!)
X, dθ, cg_results = batch_bilevel_solve(outer, inner, lmo, X0, θ)dθ is the summed hypergradient across all problems. Per-problem CG diagnostics are in cg_results.
Pre-allocated cache
For repeated solves (e.g., inside a training loop), pre-allocate a BatchCache to avoid repeated allocation:
cache = BatchCache(X0)
for epoch in 1:100
X, result = batch_solve(expr, lmo, X0; cache=cache)
endThe cache infers its backend from X0. Reusing it with a differently backed X0 raises ArgumentError.
Adaptive Tikhonov
diff_lambda is the starting Tikhonov regularization on the reduced Hessian. If the residual ratio after the adjoint solve exceeds 1e-3, Marguerite scales lambda by 5× (capped at 1.0), refactors, and resolves once. The increased value persists across pullback calls on the same state, so subsequent solves at the same problem benefit without an extra retry.
Performance tips
- Pre-allocate
BatchCacheto avoidO(nB)allocation per solve. - Use
ScalarBoxorProbSimplexon GPU: their kernels do the LMO and gap reduction in a single launch. - For training loops where the next iterate is close to the previous solution, set
tol = 1e-3rather than the default1e-4.
Benchmarks
Numbers below are from examples/bench_batched_oracles.jl on a test M-series Mac, Julia 1.12. The benchmark sweeps (n, B, T, oracle) and times three conditions per cell at a fixed iteration regime (tol=0, max_iters=500) so wall time differences reflect per-iter cost, not convergence rate noise.
Conditions:
serial_cpu—solve()calledBtimes sequentiallybatched_cpu—batch_solve()on a CPUMatrix{T}batched_gpu—batch_solve()on a device matrix. The numbers shown are forMtlMatrix{T}(Metal). On Metal,Float64is skipped because Apple Silicon GPU hardware does not support FP64; CUDA / AMDGPU support F64 natively.
The +Accel columns come from a second run with BENCH_USE_ACCELERATE=1, which loads AppleAccelerate and forwards BLAS / LAPACK through Apple's Accelerate framework (uses the AMX matrix coprocessor on Apple Silicon).
When to use which backend
Crossover points are from one test machine; the regimes generalize across vendors, the absolute numbers do not. For backend setup and per-vendor support details, see the GPU Backends page.
| Regime | Recommendation |
|---|---|
Small (n × B < 10⁴) | batched CPU. Serial is much slower; GPU overhead dominates. |
Moderate F64 (10⁴ ≤ n × B < 10⁶) | batched CPU. On Apple Silicon, add using AppleAccelerate for ~1.3–2.4× on mul!. |
Moderate F32 (10⁴ ≤ n × B < 10⁶) | batched CPU + AppleAccelerate on Apple Silicon. CUDA / AMDGPU expected to win earlier than Metal at this scale; measurements pending. |
Large F32 (n × B ≥ 10⁶) | batched GPU. Metal: ~2–5× over CPU+Accel, ~50× over serial CPU. CUDA / AMDGPU expected similar. |
| F64 on Metal | Not supported (Apple Silicon GPU hardware limit). Use Float32. CUDA / AMDGPU support F64 natively. |
On Apple Silicon, prefer Float32 when tolerance allows: AMX favors F32 and Metal MPS kernels are tuned for F32. CUDA and AMDGPU handle both precisions natively.
API Reference
Marguerite.BatchedExpression — Type
BatchedExpression(f_per_col, grad_per_col!)Per-column callbacks for batch_solve and batch_bilevel_solve. Each callback operates on a single column view of the iterate.
Fields
f_per_col(x_b, θ, b) -> Real– objective for problembat pointx_bwith shared parametersθ. Passθ = nothingfor non-parametric problems.grad_per_col!(g_b, x_b, θ, b) -> g_b– writes the length-ngradient of problembintog_b. Required: there is no auto-gradient fallback in batched mode.
f_per_col and grad_per_col! must be column independent: the value and gradient at column b depend only on x_b, not on the other columns of the batched iterate.
Marguerite.BatchSolveConfig — Type
BatchSolveConfig(; kwargs...)Configuration for batch_solve, batch_bilevel_solve, and the corresponding rrules.
Forward solve
max_iters::Int = 10000tol = 1e-4– per-problem convergence tolerancestep_rule = MonotonicStepSize()monotonic::Bool = true– reject non-improving updatesverbose::Bool = false
Differentiation
backend_ad = DEFAULT_BACKEND– AD backend for first-order gradientshvp_backend = SECOND_ORDER_BACKEND– AD backend for HVPsdiff_cg_maxiter::Int = 50diff_cg_tol = 1e-6diff_lambda = 1e-4– starting Tikhonov regularization on the reduced Hessian. Increased automatically once if the residual exceeds the recovery threshold; the increased value persists across pullback calls on the same cached state.assume_interior::Bool = false
Active set tolerance is fixed at min(tol, ACTIVE_SET_TOL_CEILING) (see Marguerite.ACTIVE_SET_TOL_CEILING); there is no separate knob.
Per-call kwargs override matching fields of config.
Marguerite.batch_solve — Function
batch_solve(expr::BatchedExpression, lmo, X0; config=BatchSolveConfig(), cache=nothing, kwargs...)
batch_solve(expr::BatchedExpression, lmo, X0, θ; config=BatchSolveConfig(), cache=nothing, kwargs...)Solve B independent Frank-Wolfe problems in lockstep:
\[\min_{x_b \in C} f_b(x_b) \quad b = 1, \ldots, B\]
or, when θ is provided,
\[\min_{x_b \in C(\theta)} f_b(x_b, \theta) \quad b = 1, \ldots, B\]
X0 is an (n, B) matrix whose columns are initial points. CPU and GPU arrays are accepted; GPU dispatch flows through KernelAbstractions.get_backend(X0).
expr is a BatchedExpression carrying per-problem callbacks for the objective and gradient. Each callback is invoked on a single column view of the iterate, not on the whole (n, B) matrix.
lmo is an oracle (applied per column). The device path covers ScalarBox, Box, ProbSimplex, Simplex. Knapsack, MaskedKnapsack, WeightedSimplex, Spectraplex, and the generic AbstractOracle fallback are CPU only.
If lmo is a ParametricOracle and θ is provided, the constraint set $C(\theta)$ is materialized at $\theta$. Parameters $\theta$ are shared across all problems.
A ChainRulesCore.rrule enables $\partial X^*/\partial\theta$ via batched implicit differentiation.
Per-call kwargs override matching fields of config.
Marguerite.batch_bilevel_solve — Function
batch_bilevel_solve(outer, inner, lmo, X0, θ; config=BatchSolveConfig(), kwargs...) -> (X, dθ, cg_results)Solve B independent inner problems and compute the summed gradient of $\sum_b L_b(x^*_b(\theta))$ w.r.t. the shared parameters $\theta$.
Each inner problem is:
\[x^*_b(\theta) = \arg\min_{x \in C(\theta)} f_b(x, \theta)\]
If lmo is a ParametricOracle, gradients flow through both the objective and constraint set via KKT adjoint differentiation.
Arguments
outer::BatchedExpression: outer loss carryingf_per_col(x_b, θ, b)andgrad_per_col!(g, x_b, θ, b).inner::BatchedExpression: inner objective with the same per-column shape.lmo: oracle (applied per column). IfParametricOracle, materialized atθ.X0:(n, B)initial point matrix.θ: shared parameter vector.
Per-call kwargs override matching fields of config.
Marguerite.batch_bilevel_gradient — Function
batch_bilevel_gradient(outer, inner, lmo, X0, θ; kwargs...) -> dθConvenience wrapper: returns only $\nabla_\theta \sum_b L_b(x^*_b(\theta))$. See batch_bilevel_solve for full documentation.
Marguerite.batch_solution_jacobian — Function
batch_solution_jacobian(expr, lmo, X0, θ; kwargs...) -> (J, BatchSolveResult)Compute the Jacobian $\partial X^*/\partial\theta \in \mathbb{R}^{nB \times m}$ for the batched parametric solve.
The Jacobian is organized as a (n*B, m) matrix where rows (b-1)*n+1 : b*n correspond to problem b. Each column is differentiated independently using the scalar _build_pullback_state plus a cached reduced Hessian factorization.
Marguerite.BatchCache — Type
BatchCache{T, M, V, VI}Pre-allocated working buffers for batched Frank-Wolfe. Each matrix buffer is (n, B) where n is the problem dimension and B is the batch size. Per-problem scalars (gap, objective, discards) are (B,) vectors.
mask_dev mirrors accepted on the device so the GPU acceptance broadcast avoids a per-iter device allocation; on CPU it aliases accepted.
active_indices_dev and nactive carry the active subset of problems for narrowing GPU kernel launches as problems converge.
No sparse vertex protocol: batched mode always uses dense vertices.
Marguerite.BatchResult — Type
BatchResult{T<:Real}Per-problem diagnostics from a batched Frank-Wolfe solve.
Fields
objectives::Vector{T}– final per-problem objective valuesgaps::Vector{T}– final per-problem FW gapsiterations::Int– total lockstep iterationsconverged::BitVector– per-problem convergence flagsdiscards::Vector{Int}– per-problem rejected non-improving updates
Marguerite.BatchSolveResult — Type
BatchSolveResult{T, M<:AbstractMatrix{T}}Wrapper for batch_solve output. Supports tuple unpacking: X, result = batch_solve(...).
Fields
X::M–(n, B)solution matrix (columns are solutions)result::BatchResult{T}– per-problem diagnostics
Marguerite.BatchBilevelResult — Type
BatchBilevelResult{T, S}Wrapper for batch_bilevel_solve output. Supports tuple unpacking: X, dθ, cg_results = batch_bilevel_solve(...).
Fields
X::Matrix{T}–(n, B)inner solutionstheta_grad::S– gradient $\nabla_\theta L(X^*(\theta))$ (summed across problems)cg_results::Vector{CGResult{T}}– per-problem CG diagnostics