Implementing a solver interface
[The interface is designed for multiple dispatch, e.g., attributes, combinations of sets and functions.]
Solver-specific attributes
Solver-specific attributes should be specified by creating an AbstractOptimizerAttribute
. For example, inside MyPackage
, we could add the following:
struct PrintLevel <: MOI.AbstractOptimizerAttribute end
function MOI.set(model::Optimizer, ::PrintLevel, level::Int)
# ... set the print level ...
end
Then, the user can write:
model = MyPackage.Optimizer()
MOI.set(model, MyPackage.PrintLevel(), 0)
Supported constrained variables and constraints
The solver interface should only implement support for variables constrained on creation (see add_constrained_variable
/add_constrained_variables
) or constraints that directly map to a structure exploited by the solver algorithm. There is no need to add support for additional types, this is handled by The Bridges submodule. Furthermore, this allows supports_constraint
to indicate which types are exploited by the solver and hence allows layers such as Bridges.LazyBridgeOptimizer
to accurately select the most appropriate transformations.
As add_constrained_variable
(resp. add_constrained_variables
) falls back to add_variable
(resp. add_variables
) followed by add_constraint
, there is no need to implement this function if model
does not require that variables be constrained when they are created. However, if model
requires that variables be constrained when they're created, then it should only implement add_constrained_variable
and not add_variable
nor add_constraint
for SingleVariable
-in-typeof(set)
. In addition, it should implement supports_add_constrained_variables(::Optimizer, ::Type{Reals})
and return false
so that these variables are bridged, see supports_add_constrained_variables
.
Handling duplicate coefficients
Solvers should expect that functions such as ScalarAffineFunction
and VectorQuadraticFunction
may contain duplicate coefficents, for example, ScalarAffineFunction([ScalarAffineTerm(x, 1), ScalarAffineTerm(x, 1)], 0.0)
. These duplicate terms can be aggregated by calling Utilities.canonical
.
x = MathOptInterface.VariableIndex(1)
term = MathOptInterface.ScalarAffineTerm(1, x)
func = MathOptInterface.ScalarAffineFunction([term, term], 0)
func_canon = MathOptInterface.Utilities.canonical(func)
func_canon ≈ MathOptInterface.ScalarAffineFunction(
[MathOptInterface.ScalarAffineTerm(2, x)], 0)
# output
true
Implementing copy
Avoid storing extra copies of the problem when possible. This means that solver wrappers should not use Utilities.CachingOptimizer
as part of the wrapper. Instead, do one of the following to load the problem (assuming the solver wrapper type is called Optimizer
):
If the solver supports loading the problem incrementally, implement
add_variable
,add_constraint
for supported constraints andset
for supported attributes and add:function MOI.copy_to(dest::Optimizer, src::MOI.ModelLike; kws...) return MOI.Utilities.automatic_copy_to(dest, src; kws...) end
with
MOI.Utilities.supports_default_copy_to(model::Optimizer, copy_names::Bool) = true
or
MOI.Utilities.supports_default_copy_to(model::Optimizer, copy_names::Bool) = !copy_names
depending on whether the solver support names; see
Utilities.supports_default_copy_to
for more details.If the solver does not support loading the problem incrementally, do not implement
add_variable
andadd_constraint
as implementing them would require caching the problem. Let users or JuMP decide whether to use aCachingOptimizer
instead. Write either a custom implementation ofcopy_to
or implement the Allocate-Load API. If you choose to implement the Allocate-Load API, dofunction MOI.copy_to(dest::Optimizer, src::MOI.ModelLike; kws...) return MOI.Utilities.automatic_copy_to(dest, src; kws...) end
with
MOI.Utilities.supports_allocate_load(model::Optimizer, copy_names::Bool) = true
or
MOI.Utilities.supports_allocate_load(model::Optimizer, copy_names::Bool) = !copy_names
depending on whether the solver support names; see
Utilities.supports_allocate_load
for more details.Note that even if both writing a custom implementation of
copy_to
and implementing the Allocate-Load API requires the user to copy the model from a cache, the Allocate-Load API allows MOI layers to be added between the cache and the solver which allows transformations to be applied without the need for additional caching. For instance, with the proposed Light bridges, no cache will be needed to store the bridged model when bridges are used by JuMP so implementing the Allocate-Load API will allow JuMP to use only one cache instead of two.
JuMP mapping
MOI defines a very general interface, with multiple possible ways to describe the same constraint.
This is considered a feature, not a bug.
MOI is designed to make it possible to experiment with alternative representations of an optimization problem at both the solving and modeling level.
When implementing an interface, it is important to keep in mind that the way the user can express problems in JuMP is not directly limited by the constraints which a solver supports via MOI as JuMP performs automatic reformulation](@ref) via The Bridges submodule.
Therefore, we recommend to only support the constraint types that directly map to a structure exploited by the solver algorithm.
The following bullet points show examples of how JuMP constraints are translated into MOI function-set pairs:
@constraint(m, 2x + y <= 10)
becomesScalarAffineFunction
-in-LessThan
@constraint(m, 2x + y >= 10)
becomesScalarAffineFunction
-in-GreaterThan
@constraint(m, 2x + y == 10)
becomesScalarAffineFunction
-in-EqualTo
@constraint(m, 0 <= 2x + y <= 10)
becomesScalarAffineFunction
-in-Interval
@constraint(m, 2x + y in ArbitrarySet())
becomesScalarAffineFunction
-in-ArbitrarySet
.
Variable bounds are handled in a similar fashion:
@variable(m, x <= 1)
becomesSingleVariable
-in-LessThan
@variable(m, x >= 1)
becomesSingleVariable
-in-GreaterThan
One notable difference is that a variable with an upper and lower bound is translated into two constraints, rather than an interval. i.e.:
@variable(m, 0 <= x <= 1)
becomesSingleVariable
-in-LessThan
andSingleVariable
-in-GreaterThan
.
Solvers are not expected to support AbstractScalarFunction
in GreaterThan
, LessThan
, EqualTo
, or Interval
with a nonzero constant in the function. Constants in the affine function should instead be moved into the parameters of the corresponding sets. The ScalarFunctionConstantNotZero
exception may be thrown in this case.
Column Generation
There is no special interface for column generation. If the solver has a special API for setting coefficients in existing constraints when adding a new variable, it is possible to queue modifications and new variables and then call the solver's API once all of the new coefficients are known.
Problem data
All data passed to the solver should be copied immediately to internal data structures. Solvers may not modify any input vectors and should assume that input vectors may be modified by users in the future. This applies, for example, to the terms
vector in ScalarAffineFunction
. Vectors returned to the user, e.g., via ObjectiveFunction
or ConstraintFunction
attributes, should not be modified by the solver afterwards. The in-place version of get!
can be used by users to avoid extra copies in this case.
Statuses
Solver wrappers should document how the low-level statuses map to the MOI statuses. Statuses like NEARLY_FEASIBLE_POINT
and INFEASIBLE_POINT
, are designed to be used when the solver explicitly indicates that relaxed tolerances are satisfied or the returned point is infeasible, respectively.
Naming
MOI solver interfaces may be in the same package as the solver itself (either the C wrapper if the solver is accessible through C, or the Julia code if the solver is written in Julia, for example). The guideline for naming the file containing the MOI wrapper is src/MOI_wrapper.jl
and test/MOI_wrapper.jl
for the tests. If the MOI wrapper implementation is spread in several files, they should be stored in a src/MOI_wrapper
folder and included by a src/MOI_wrapper/MOI_wrapper.jl
file.
By convention, optimizers should not be exported and should be named PackageName.Optimizer
. For example, CPLEX.Optimizer
, Gurobi.Optimizer
, and Xpress.Optimizer
.
Testing guideline
The skeleton below can be used for the wrapper test file of a solver named FooBar
.
using Test
using MathOptInterface
const MOI = MathOptInterface
const MOIT = MOI.Test
const MOIU = MOI.Utilities
const MOIB = MOI.Bridges
import FooBar
const OPTIMIZER_CONSTRUCTOR = MOI.OptimizerWithAttributes(
FooBar.Optimizer, MOI.Silent() => true
)
const OPTIMIZER = MOI.instantiate(OPTIMIZER_CONSTRUCTOR)
@testset "SolverName" begin
@test MOI.get(OPTIMIZER, MOI.SolverName()) == "FooBar"
end
@testset "supports_default_copy_to" begin
@test MOIU.supports_default_copy_to(OPTIMIZER, false)
# Use `@test !...` if names are not supported
@test MOIU.supports_default_copy_to(OPTIMIZER, true)
end
const BRIDGED = MOI.instantiate(
OPTIMIZER_CONSTRUCTOR, with_bridge_type = Float64
)
const CONFIG = MOIT.TestConfig(atol=1e-6, rtol=1e-6)
@testset "Unit" begin
# Test all the functions included in dictionary `MOI.Test.unittests`,
# except functions "number_threads" and "solve_qcp_edge_cases."
MOIT.unittest(
BRIDGED,
CONFIG,
["number_threads", "solve_qcp_edge_cases"]
)
end
@testset "Modification" begin
MOIT.modificationtest(BRIDGED, CONFIG)
end
@testset "Continuous Linear" begin
MOIT.contlineartest(BRIDGED, CONFIG)
end
@testset "Continuous Conic" begin
MOIT.contlineartest(BRIDGED, CONFIG)
end
@testset "Integer Conic" begin
MOIT.intconictest(BRIDGED, CONFIG)
end
Test functions like MOI.Test.unittest
and MOI.Test.modificationtest
are wrappers around corresponding dictionaries MOI.Test.unittests
and MOI.Test.modificationtests
. The keys of each dictionary (strings describing the test) map to functions that take two arguments: an optimizer and a MOI.Test.TestConfig
object. Exclude tests by passing a vector of strings corresponding to the test keys you want to exclude as the third positional argument to the test function (e.g., MOI.Test.unittest
).
Print a list of all keys using println.(keys(MOI.Test.unittests))
The optimizer BRIDGED
constructed with instantiate
automatically bridges constraints that are not supported by OPTIMIZER
using the bridges listed in Bridges. It is recommended for an implementation of MOI to only support constraints that are natively supported by the solver and let bridges transform the constraint to the appropriate form. For this reason it is expected that tests may not pass if OPTIMIZER
is used instead of BRIDGED
.
To test that a specific problem can be solved without bridges, a specific test can be run with OPTIMIZER
instead of BRIDGED
. For instance
@testset "Interval constraints" begin
MOIT.linear10test(OPTIMIZER, CONFIG)
end
checks that OPTIMIZER
implements support for ScalarAffineFunction
-in-Interval
.
If the wrapper does not support building the model incrementally (i.e. with add_variable
and add_constraint
), then Utilities.supports_default_copy_to
can be replaced by Utilities.supports_allocate_load
if appropriate (see Implementing copy).