Writing solver-generic code¶
Switching backend in a single program is a two-line change (Choosing a solver). Writing a function — a model builder, a decomposition, an experiment driver — that works on any backend is barely harder, and it is what makes cross-solver benchmarking, and reusable research code, practical. This page shows the patterns.
Take the model as a template parameter¶
Model classes share no base class; they share concepts. A generic function states which ones it needs:
template <milp_model Model>
void build_assignment(Model & model, const instance & data) {
using namespace mippp::operators;
auto X = model.add_binary_variables(
data.n * data.n, [n = data.n](int i, int j) { return i * n + j; });
model.set_minimization();
model.set_objective(xsum(data.pairs, [&](auto && p) {
auto && [i, j] = p; return data.cost[i][j] * X(i, j); }));
// ...
}
Called with highs_milp, gurobi_milp, scip_milp, … it compiles for each; called with an LP-only class, it fails immediately with "constraint not satisfied: milp_model" rather than with a page of overload-resolution noise.
Member types are reached through the model rather than spelled out:
using variable = model_variable_t<Model>; // or typename Model::variable
using constraint = model_constraint_t<Model>;
using scalar = model_scalar_t<Model>; // double on current backends
This matters when you store handles: a std::vector<typename Model::variable> is the portable spelling of "the columns I created".
Require the capabilities you use¶
Optional features are concepts too, so an algorithm can advertise its requirements in its signature:
template <typename Model>
requires milp_model<Model> && has_candidate_solution_callback<Model>
void solve_tsp(Model & model, const instance & data);
template <typename Model>
requires lp_model<Model> && has_dual_solution<Model> && has_add_column<Model>
double column_generation(Model & master, const instance & data);
Instantiating solve_tsp with a backend that has no callback support is a compile-time error naming the missing capability — not a surprise at hour three of a run. The same check is available as an assertion:
Degrade gracefully with if constexpr¶
When a capability improves a run but is not essential, branch on it. The unsupported branch is never instantiated, so the code still compiles for every backend:
if constexpr(has_time_limit<Model>) model.set_time_limit(budget);
if constexpr(has_mip_start<Model>) model.add_mip_start(greedy_solution);
if constexpr(has_optimality_tolerance<Model>)
model.set_optimality_tolerance(1e-6);
// resolve infeasible_or_unbounded where the backend can, before recording
if constexpr(has_refinable_lp_status<Model>) model.refine_lp_status();
record(model.solve_status());
This is the idiom to prefer over #ifdefs or per-solver overloads: the feature test lives next to the feature use, and the set of backends a function supports is deduced rather than maintained by hand.
Selecting the backend at run time¶
Compile the generic function once per backend and dispatch on a string — the shape of every --solver command-line flag:
template <typename Api, typename Model>
run_record run_instance(const instance & data) {
Api api; // loads that solver's library, here and now
Model model(api);
build_assignment(model, data);
return run(model, 600s);
}
run_record dispatch(std::string_view solver, const instance & data) {
if(solver == "highs") return run_instance<highs_api, highs_milp >(data);
if(solver == "gurobi") return run_instance<gurobi_api, gurobi_milp>(data);
if(solver == "scip") return run_instance<scip_api, scip_milp >(data);
throw std::invalid_argument("unknown solver");
}
Because solver libraries are loaded when the api object is constructed, a binary built with all three branches runs fine on a machine that only has HiGHS installed — as long as the other branches are not taken. A missing library throws a descriptive std::runtime_error from the api constructor, which a benchmark driver can catch and record as "backend unavailable".
Constructing an api is the expensive step: hoist it out of the loop and build many models from the same one.
A cross-solver benchmark¶
for(auto && inst : instances) {
for(auto && solver : {"highs", "gurobi", "cplex"}) {
try {
auto rec = dispatch(solver, inst);
std::println("{},{},{},{},{}", inst.name, solver, rec.seconds,
rec.objective, rec.optimal);
} catch(const std::runtime_error & e) {
std::println("{},{},unavailable", inst.name, solver);
}
}
}
The model is built by the same code in every row of that table — which is the point: differences in the numbers come from the solvers, not from three modeling layers with three sets of defaults.
Model building stays well under a second even at a million variables — 72 ms through Cbc, 153 ms through HiGHS, and the spread across backends is the solvers' own build APIs, not the abstraction. That is negligible next to the solve; and when the build is what you are measuring, that is exactly what mippp_nqueens reports — see Performance.
Writing generic code over expressions¶
Functions that take expressions rather than models follow the concepts of the expression layer:
template <linear_expression E>
void log_expression(E && e); // take by forwarding reference, and forward
Taking E && is not a stylistic choice: some expression types can only be read through a non-const lvalue, and some are single-use. The rules — and the diagnostics that fire when they are broken — are in Inside the expression layer.
Next¶
Worked examples — complete programs applying all of this.