Bridges as an extended dispatch system

By: Julia on μβ

Re-posted from: https://matbesancon.github.io/post/2019-09-12-bridging-indicator/


The progress of mathematical optimization as a domain has been tightly
coupled with the development and improvement of computational methods and
their implementations as computer programs. As observed in the recent
MIPLIB compilation 1, the quantification of method performance in
optimization cannot really be split from the experimental settings, solver
performance is far from a theoretical science.

Different methods and implementations manipulate different data
structures to represent the same optimization problem.
Reformulating optimization models has often been the role and responsibility
of the practitioner, transforming the application problem at hand to fit a
standard form that a given solver accepts as input for a solution method.
Interested readers may find work on formal representation of optimization
problems as data structures by Liberti et al23.
Mapping a user-facing representation of an object into a semantically
equivalent internal representation is the role of compilers.
For mathematical optimization specifically, Algebraic Modelling Languages
(AML) are domain-specific languages (and often an associated compiler and runtime)
turning a user-specified code into data structures passed to solvers. Examples
of such languages are JuMP, Pyomo, GAMS or AMPL; the first two being embedded in
a host language (Julia and Python respectively), while the two last are
stand-alone with their own compiler and runtime.

We will focus in this post on MathOptInterface.jl
(MOI) which acts as a second layer of the compilation phase of an AML.
The main direct user-facing language for this is JuMP,
which has already been covered in other resources45.
When passed to MOI, the problem has been read from the user code but not
reformulated yet. In compiler terms, MOI appears after the parsing phase:
the user code has been recognized and transformed into corresponding internal
structures.

Table of Contents

Re-formulating problems using multiple dispatch

Multiple dispatch is the specialization of code depending on the arity and type
of arguments. When multiple definitions (methods) exist for a function, the types
of the different arguments are used to determine which definition is compatible.
If several definitions are compatible, the most specific with respect to the
position in the type hierarchy is selected. If several definitions are compatible
without a total ordering by specificity, the method call is ambiguous, which raises an error.
More information on the dispatch system in Julia can be found
in the seminal article and the recent talk on
multiple dispatch.
See the following examples for the basic syntax:

f(x) = 3 # same as f(x::Any) = 3
f(x::Int) = 2x

# dispatch on arity
f(x, y) = 2

# defining and dispatching on a custom type
struct X
  value::Float64
end

f(x::X) = 3 * x.value

In this section, we will consider the reformulation of problems
using multiple dispatch. In a generic form, an optimization problem can be
written as:

$$\begin{align} \min_{x} \,\,& f(x) \\ \text{s.t.}\\ & F_i(x) \in S_i & \forall i \end{align} $$

The example of linear constraints

We will build a reformulation system leveraging multiple dispatch.
Assuming the user code is already parsed, the problem input can be represented
as function-set pairs $(F_i, S_i)$. If we restrict this to individual linear
constraints, all functions are of the form:
$$ F_i(x) = a_i^T x $$

The three types of sets are:

  • LessThan(b): $ y \in S_i \Leftrightarrow y \leq b $
  • GreaterThan(b): $ y \in S_i \Leftrightarrow y \geq b $
  • EqualTo(b): $ y \in S_i \Leftrightarrow y = b $
abstract type ConstraintSet end

struct LessThan{T} <: ConstraintSet
    b::T
end

struct GreaterThan{T} <: ConstraintSet
    b::T
end

struct EqualTo{T} <: ConstraintSet
    b::T
end

abstract type ScalarFunction end

struct ScalarAffineFunction{T} <: ScalarFunction
    a::Vector{T}
    x::Vector{VariableIndex}
end

Now that the fundamental structures are there, let us think of a solver based
on the simplex method, accepting only less-or-equal linear constraints.
We will assume a Model type has been defined, which supports a function
add_constraint!(m::Model, f::F, s::S), which adds a constraint of type F in S.

function add_constraint!(m::Model, f::ScalarAffineFunction, s::LessThan)
    pass_to_solver(m.solver_pointer, f, s)
end

function add_constraint!(m::Model, f::ScalarAffineFunction{T}, s::GreaterThan{T}) where {T}
    # a^T x >= b <=> -a^T x <= b
    leq_set = LessThan{T}(-s.b)
    leq_function = ScalarAffineFunction(-f.a, f.x)
    add_constraint!(m, leq_function, leq_set)
end

function add_constraint!(m::Model, f::ScalarAffineFunction, s::EqualTo)
    # a^T x == b <=> a^T x <= b && a^T x >= b
    leq_set = LessThan(s.b)
    geq_set = LessThan(s.b)
    leq_function = copy(f)
    geq_function = copy(f)
    add_constraint!(m, leq_function, leq_set)
    add_constraint!(m, geq_function, geq_set)
end

The dispatching rules of that program can be determined statically
and define the sequence of method calls:

graph TD;
    E[EqualTo] --> G[GreaterThan];
    E[EqualTo] --> L[LessThan];
    G[GreaterThan] --> L[LessThan];
    L[LessThan] --> S[Solver];

At each call site, exactly one method is determined to be the appropriate
one to use by the dispatch mechanism.

Unique dispatch and multiple solvers

Let us now consider that another solver is integrated into our dispatch-based
optimization framework, but supporting only GreaterThan constraints.
The new method call diagram is:

graph TD;
    E[EqualTo] --> G[GreaterThan];
    E[EqualTo] --> L[LessThan];
    L[LessThan] --> G[GreaterThan];
    G[GreaterThan] --> S[Solver];

Considering that we wish to define one reformulation graph for all solvers,
two possibilities occur:

  1. Which path should be used is encoded in types.
  2. The method called from a given node depends on runtime parameters.

The first option could appear more efficient, but as the number of nodes, arcs
and solvers grow, compilation is rendered impossible, as one would have to
recompute complete programs based on the addition of solvers or reformulations.
The second option requires tools other than dispatch, since this mechanism
uses precisely the types to determine the method. It is to tackle this problem
of reformulating problems in graph above that the bridge system was developed
in MOI.

The bridge system

The bridge system emerged as a solution to tackle the rapidly-growing
number of supported functions, sets and constraints as function-set pairs.
A bridge is the instantiation in the reformulation system of an arc in
the diagram presented above. It is defined by:

  • The type of constraint it is replacing, represented by its function-set pair $(F_0, S_0)$.
  • The type of constraints which must be supported for the reformulation, as a collection of function-set pairs $[(F_i, S_i)]$.
  • The reformulation method itself which takes the initial constraint, creates the necessary variables and constraints and adds them to the model. In a Haskell-like notation, the declarative part of the bridge can be modelled with the following signature:
    $$ ([x_0], F_0, S_0) \rightarrow ([x_1], [(F_i,S_i)]) $$

where $[x_0]$ is a collection of variables used by the initial constraint,
$[x_1]$ is the collection of newly created variables, and the $(F_i,S_i)$ are the newly created constraints.

Bridge implementation

The bridge definition and most implementations live in the MathOptInterface.Bridges module.
It consists of an abstract type AbstractBridge and some functions that bridges must implement.

We will see the greatly reduced example of a bridge type MyBridge adding support for two types
of constraints. The following code declares what the bridge does:

abstract type AbstractBridge end

struct MyBridge1 <: AbstractBridge end

struct MyBridge2 <: AbstractBridge end

"""
By default, bridges do not support a constraint `F-in-S`
"""
function MOI.supports_constraint(::Type{<:AbstractBridge}, ::Type{F}, ::Type{S}) where {F, S}
    return false
end

"""
MyBridge1 supports `F1 in S1`
"""
function MOI.supports_constraint(::Type{MyBridge1}, ::Type{F1}, ::Type{S1})
    return true
end

"""
MyBridge2 supports `F2 in S2`
"""
function MOI.supports_constraint(::Type{MyBridge2{F2,S2}}, ::Type{F2}, ::Type{S2})
    return true
end

"""
Bridging a `F1 in S1` with `MyBridge1` requires creating constraints of type `F3 in S3` and `F3 in S4`
"""
added_constraint_types(::Type{MyBridge1})
    return [(F3, S3), (F3, S4)]
end

"""
Bridging a `F2 in S2` with `MyBridge2` requires creating constraints of type `F3 in S3`
"""
added_constraint_types(::Type{MyBridge2})
    return [(F3, S3)]
end

What these method implementations declare is the following structure:

graph LR;
    F1[F1 in S1] -- B1 --> F33[F3 in S3];
    F1[F1 in S1] -- B1 --> F34[F3 in S4];
    F2[F2 in S2] -- B2 --> F33[F3 in S3];

Unlike dispatch, multiple possible bridges can be defined for a given constraint $F_1 \in S_1$.
In optimization, this corresponds to multiple possible reformulations of a given constraint.

Now that the bridges behaviour have been defined, their implementation have to be given,
again in a trimmed version of the real MOI code:

function bridge_constraint(::Type{MyBridge1}, model::MOI.ModelLike, f::F1, s::S1)
    (f3, s3) = transform_constraint_first_component(f, s)
    s4 = transform_constraint_second_set(f, s)
    new_constraint3 = MOI.add_constraint(model, f3, s3)
    new_constraint4 = MOI.add_constraint(model, f3, s4)
    return MyBridge1(new_constraint3, new_constraint4)
end

function bridge_constraint(::Type{MyBridge2}, model::MOI.ModelLike, f::F2, s::S2)
    (f3, s3) = transform_constraint_first_component(f, s)
    new_constraint3 = MOI.add_constraint(model, f3, s3)
    return MyBridge2(new_constraint3)
end

Finally, the graph is for the moment split across different bridges.
The multiple dispatch mechanism uses a method table,
the bridge system uses a bridge optimizer which stores all bridges and
thus contains the necessary information to convert a constraint to a supported form.

Problem reformulation heuristics

A bridge optimizer takes a given problem, a solver and the set of bridges,
all of which representable in a single hyper-graph, a graph with possibly
multiple edges between two given nodes.

$P$ represents the initial problem, pointing to the constraints it contains.
There is an edge from $C_i$ to $C_j$ for each bridge reformulating $C_i$
using at least a $C_j$ constraint. A constraint $C_i$ points to $S$ if the solver
natively supports the constraint.

Some bridges require defining multiple new constraints. That is the case of $B_5$
reformulating $C_6$ using $C_3$ and $C_4$. On the contrary, $C_3$ can be re-formulated
either in $C_2$ using $B_2$ or in $C_4$ using $B_3$. In this setting, reformulating
it in $C_2$ is appropriate, but may change depending on the solver.
A potential large number of bridges could be introduced without being on any
problem-solver path. For instance, there will likely be no semi-definite cone
constraint when the problem at hand is linear, and $S$ a simplex-based solver.
Without reasoning on specific constraints, it is hard to picture which
reformulation is efficient.

The current bridging decision is based on a shortest-path heuristic.
One bridge is considered a unit distance, and a shortest path from all
user-facing constraints to all solver-compatible constraints is determined.
More precisely, a Bellman-Ford
type shortest path is used.

Perspective & conclusion

MathOptInterface.jl may be one of the greatest strength of the JuMP ecosystem:
setting the abstractions right allows the developers to integrate more exotic
constraint types in a consistent manner.
Optimization practitioners do not limit themselves to linear and
mixed-integer problems, following improvements in performance and variety
of solvers, the recent JuMP session at JuliaCon 20196 lays out the
motivation and structure of MOI, and recent
developments it enabled.
The type-based Function in Set structure keeps the underlying
machinery familiar to both optimization scientists formulating problems in a close
fashion and Julia programmers leveraging multiple dispatch.

Transforming optimization problems using the bridge system is transparent,
leaving the option for advanced users to pick which paths are chosen
in the hypergraph. In the scenario where MOI was not performing these operations,
the two options are:

  • Reformulations by the modelling language: this may mean a systematic
    overhead cost of using the user-facing modelling language, especially if the used
    reformulation is not ideal for a specific problem. This also creates a barrier for
    other modelling languages to emerge, since a great deal of work has gone in
    reformulations of the user-input. The two-layer structure of JuMP + MOI has enabled
    different languages such as Parametron.jl
    or Convex.jl to emerge, sharing the same
    solver interfaces and middle infrastructure. The monolithic modelling environments
    historically dominant in mathematical optimization may explain to some extent why
    a large part of the optimization literature is working with solver APIs directly,
    thus loosing any ability to switch solver later.
  • Reformulations by the solver: this is currently done for a lot of constraints,
    without always being transparent on which reformulation is applied and what the
    end-model is. This can lead to surprising behaviour when switching solvers
    or passing a different formulation of the same problem, without having access
    to what happens under the hood in a black-box proprietary solver.

The MOI system thus helps present and future researchers to avoid the pitfalls
of the two-language problem of mathematical optimization.

Further resources

The diagrams were designed using MermaidJS & draw.io.


  1. MIPLIB 2017: Data-Driven Compilation of the 6th Mixed-Integer Programming Library, Gleixner, Ambros and Achterberg, Tobias and Christophel, Philipp and Lübbecke, Marco and Ralphs, Ted K and Hendel, Gregor and Gamrath, Gerald and Bastubbe, Michael and Berthold, Timo and Jarck, Kati and others, 2019.
    ^
  2. Liberti, Leo. “Reformulations in mathematical programming: Definitions and systematics.” RAIRO-Operations Research 43.1 (2009): 55-85. Preprint
    ^
  3. Liberti, Leo and Cafieri, Sonia and Tarissan, Fabien, Reformulations in Mathematical Programming: A Computational Approach, DOI, Preprint
    ^
  4. JuMP initial paper https://doi.org/10.1137/15M1020575
    ^
  5. JuMP tutorial at JuliaCon2018: https://www.youtube.com/watch?v=7tzFRIiseJI
    ^
  6. MathOptInterface, JuMP extensions and MOI-based solvers at JuliaCon2019: https://www.youtube.com/watch?v=cTmqmPcroFo
    ^

Constraint Solver Part 3: More Pruning & Benchmarking

Series: Constraint Solver in Julia

This is part 3 of the series about: How to build a Constraint Programming solver?

In this part we want to make a minor but important update to the old post as I missed something as well as doing some bechmarking with other constraint programming solvers or specialized sudoku solvers.

Let’s have a look at the video from the last post:

Explanation:

Last time we have set a value while backtracking and then removed values from the search space and maybe set new values due to fixing this single value.

  • black: Given values
  • blue:
    • beginning: pruned before backtracking
    • later: set or pruned during backtracking (might be wrong)
  • green cell: last set by backtracking
  • orange cell: pruned by green cell (or later other orange cells)

At that moment I didn’t completely think that through and only when I had a look at the visualization I made I realized that it’s a bit stupid not to further propagate the new changes as well.

That is the only change on the code basis for this post and after that I’ll show you some benchmarking results which will not be that awesome yet as it’s more like a spoiler and preparation for the next post which will be longer.

So after the first pruning in our backtrack function: GitHub Line

we add a part to prune further on the fixed values:

# prune on fixed vals
co_idx = 1
constraint_idxs_dict = Dict{Int, Bool}()
# get all constraints which need to be called (only once)
while co_idx < length(constraint_outputs)
    constraint_output = constraint_outputs[co_idx]
    for fixed_ind in keys(constraint_output.fixed)
        inner_constraints = com.constraints[com.subscription[fixed_ind]]
        for constraint in inner_constraints
            constraint_idxs_dict[constraint.idx] = true
        end
    end
    co_idx += 1
end
constraint_idxs = collect(keys(constraint_idxs_dict))

There we just check which constraints need to be called again and the in a while loop we call the constraints and add constraints to our list if some cell got fixed:

con_counter = 0
# while we haven't called every constraint
while length(constraint_idxs_dict) > 0
    con_counter += 1
    constraint = com.constraints[constraint_idxs[con_counter]]
    delete!(constraint_idxs_dict, constraint.idx)

    constraint_output = constraint.fct(com, constraint.indices; logs = false)
    push!(constraint_outputs, constraint_output)
    push!(constraints, constraint)
    if !constraint_output.feasible
        feasible = false
        break
    end

    # if we fixed another value => add the corresponding constraint to the list
    # iff the constraint will not be called anyway in the list 
    for ind in keys(constraint_output.idx_changed)
        for constraint in com.constraints[com.subscription[ind]]
            if !haskey(constraint_idxs_dict, constraint.idx)
                constraint_idxs_dict[constraint.idx] = true
                push!(constraint_idxs, constraint.idx)
            end
        end
    end
end

Now you might say that looks awful why is that so much code and it seems like we did that already. You know what, you’re damn right.
Therefore the code gets it’s own function prune!(com, constraints, constraint_outputs) and returns feasible, constraints, constraint_outputs and it will also be called in our main solve function directly after calling every constraint function ones.

Additionally I found that we always backtrack from left to right and then pruning isn’t working that well in Sudoku because all the surrounding cells are already fixed.

Therefore…

How my Julia coding style changed

By: Tamás K. Papp

Re-posted from: https://tamaspapp.eu/post/2019-09-07-coding_style_retrospective/

The recent redesign of DynamicHMC.jl provides an opportunity to reflect on how my Julia coding style changed. I started this package two years ago, and did not do any major refactoring after it became usable, mostly because I did not have a good comprehensive test suite or a solid conceptual understanding of all the ramifications of the algorithm, especially the adaptation, and I was afraid of breaking something that I just got working. Consequently, the code before the redesign largely reflected the coding style I developed for Julia 0.5 and then partly 0.6.

I am one of those people who recognize the benefits of reflecting on the past from time to time, but are too busy lazy to keep a diary. But code under version control is effectively a diary about the life of software, and I find it interesting to think about how my Julia coding style has changed in the past to years.

It is important to emphasize that this writeup reflects how I code at the moment. I recognize that other coding styles in Julia can be perfectly legitimate, and when I contribute to packages, I do my best to follow existing style. Also, it is very likely that my own coding style will change as the language evolves. In this blog post, I won’t address details of coding style about which there is a more or less general agreement — see the Style Guide in the manual and YASGuide for useful summaries. Finally, some examples are stylized adaptations of real code for simpler exposition.

Randomness

Random numbers are very important for scientific computing, but at the same time present an important challenge for writing bug-free parallel code and unit testing. A lot of code in Random and Base follows the interface conventions

rand([rng], ...)

making the random number generator an optional first argument, and defaulting to the global one.

While this can be convenient for user code, in packages I find that it is better to keep the random number generator as an explicit argument to almost all methods. This paves the way for making the code work nicely with composable multi-threading too.

If random and deterministic parts of the algorithm can be separated, it should be done, as it usually makes the code cleaner, and unit testing much easier. An example of this is factoring out the random directions for tree doubling in NUTS: drawing a single set of bit flags once and providing this to the tree building algorithm, the latter can treat the doubling directions as predetermined. This also made it much easier to verify detailed balance in the unit tests.

Named tuples

Named tuples are extremely useful for organizing multiple values as inputs and outputs when a composite type (struct) is not warranted or needed. If a function returns multiple values, especially more than two, I now always default to using named tuples, ie

function f(x, y)
    a = ...
    b = ...
    c = ...
    (a = a, b = b, c = c)
end

then use @unpack from Parameters.jl for destructuring:

@unpack a, b = f(x, y) # I don't need c here

This is costless and makes the interfaces more flexible: for example, I can add extra fields, or eventually make the returned value a composite type later, without changing anything in the callers.

Multi-liners as opposed to one-liners

Almost all the “clever” one liners are now expunged from the code. Compare

EBFMI(x) = (πs = map(x -> x.π, x); mean(abs2, diff(πs)) / var(πs))

with

function EBFMI(tree_statistics)
    πs = map(x -> x.π, tree_statistics)
    mean(abs2, diff(πs)) / var(πs)
end

The first one is kind of crammed, and difficult to read, while the second one makes it clear that an interim value is obtained for the calculation.

When a function returns multiple values, I find it nice to assign each to a variable first, ie

function f(x, y)
    a = g(x, y)
    b = h(x, y)
    a, b
end

as opposed to

f(x, y) = g(x, y), h(x, y)

This makes the returned structure much more readable when I need to look at some code later.

That said, I still have a strong preference for writing small functions that “do one thing”. This again is frequently an optimal strategy for Julia, as the compiler is very good about inlining decisions (and when not, can be nudged with @inline).

Abstract types can be an implementation detail

Abstract types can be very useful for organizing method dispatch: code like

abstract type SomeAbstractType end

f(x::SomeAbstractType) = do_something()

struct ThatSubType{T} <: SomeAbstractType
    y::T
end

g(x::ThatSubType) = do_something_else(x.y)

can help avoid repetition.

But abstract types are just one convenient tool for this, and building a type tree is a tool, not an end in itself. When they are no longer adequate, other solutions such as traits should be used instead.

Consequently, these days I mostly consider type hierarchies an implementation detail and I am wary of exposing them in the API of a package. This is especially important if some functionality requires that the user of a package implements their own custom types, e.g. for a model. Requiring that

UserDefinedModel <: SomePackage.ModelType

to work with some API is usually unnecessary, and can cause unnecessary complications when the user would want UserDefinedModel to implement some other API. Either duck typing, or an explicit interface check like

implements_model(::Any) = false
implements_model(::Type{<:UserDefinedModel}) = true

can work better (returning types when that is necessary, eg for further dispatch — see Base.IteratorSize for an example).

Small type unions

The ability of the compiler create efficient code for small type unions made code organization much easier for me. For example, DynamicHMC.adjacent_tree attempts to build a tree next to some position, but this can fail because of divergence or turning in the leapfrog integrator, in which case it is useful to bail out early and return information about the reason. Consequently, this function either returns a tree, or an InvalidTree, which is a container with the aforementioned diagnostic information. The compiler recognizes this and generates fast code.

Some attention should be paid to avoid combinatoric type explosions, but when used well, small type unions can make code much simpler at little or no extra cost.

Functional is often the best solution

Preallocating containers for outputs can make code faster, but at the cost of extra complexity, especially that of having to figure out type parameters for said containers.

My general strategy is to write code in a functional, side-effect-free style until I am really convinced that preallocation would help, and then confine it to carefully compartmentalized parts of the code. This is of course ideal only for some kinds of code — when allocations dominate, a functional style is not optimal.

That said, Julia’s compiler is getting more and more clever about optimizing allocations, so I frequently find that I never rewrite code with preallocated containers, and occasionally even revert from preallocated code to a functional style without a relevant performance cost.

When in doubt, document

Some readers of the code of DynamicHMC.jl will have the impression that it is overdocumented. Probably 20–25% of the lines in the source are docstrings, and most internal interfaces and convenience functions have one.

Primarily, this is driven by instances of the embarassing realization of having written some code two years ago which kind of works, but no longer being sure of all the details that would be nice to know before refactoring. But another consideration is encouraging contributions, or uses of the packages for research — besides being an implementation of the NUTS sampler, DynamicHMC.jl is also a platform for experimenting, which is easier to do when the internals are documented.

Julia’s documentation ecosystem is now very mature and robust. Besides the excellent Documenter.jl, I also find DocStringExtensions.jl very useful.

Internal interfaces have high return on investment

Many packages, especially those that are work in progress or experimental, do not document their user interfaces very carefully. This is of course understandable, but I have developed a preference for abstracting out and documenting interfaces in internally used code, too, as the package matures.

This makes refactoring and compartmentalization much easier, and makes sense even if a particular interface has only a single implementation in the package, because unit tests can just provide a “dummy” implementation and test related code that way. An example of this is the code for “trees” and the related tests: trees here are an abstraction for walking integer positions in two directions. This is key for the NUTS algorithm, but can be handled as an abstraction separate from the details of leapfrog integrators.