Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bridges] Add FixParametricVariablesBridge #2092

Closed
wants to merge 13 commits into from

Conversation

sstroemer
Copy link

Idea

Preface: This PR was moved over from PR#3211 in JuMP.jl. It is not meant as an actual PR, but more as starting point for the discussion of a possible implementation for (partial, not generally viable) parameters in (MI)LP models. I copied the relevant paragraphs.

The main idea originated while skimming through this rework in PowerSimulations.jl: using fixed variables as parameters. While (from now on I'm only thinking about LPs) this works easily as long as the parameters are part of b (thought as in some constraints like Ax <= b), because the solver just eliminates them during presolve. However, as soon as they are part of Ax, they actually create an expression param * var, and since both of them are actually JuMP.VariableRef this will entail the creation of a QuadExpr (instead of an AffExpr).

After asking about "multiplicative parameters", the idea (see here for the first steps) was that - since solvers like HiGHS actually do not support quadratic terms in the constraints - adding a MathOptInterface bridge that converts MOI.ScalarQuadraticFunction into MOI.ScalarAffineFunction could

  1. take the initial constraint,
  2. find all quadratic terms in the underlying function,
  3. lookup the values of all fixed variables that are involved,
  4. replace the constraint by one containing an affine function were all fixed variables are replaced by their values

Therefore the usage of a "ParameterRef" would then entail:

  1. creating a variable and fixing it to some value
  2. using it like a constant (the "variable" will be replaced by its value before being passed to the solver)
  3. calling set_value(param, val) = fix(param, val; force=true) automatically takes care of updating the parameter's value

Initial prototype

I added the code that @odow proposed.

A simple test model is available in this gist. It implements a basic concept of something generating (gen) a commodity (here: heat) that can be stored (charge/discharge) into state (with some losses due to efficiency), while making sure that a given demand is met. All that for each timestep. The parameters are: the demand (RHS / constant), the cost (coefficient in the objective function) of generation, and the conversion efficiency cop (coefficient in the constraints). Those are randomized. While simple, this is a pretty "hard" example, since 50% of all constraints contain a multiplicative parameter that needs to be adjusted.

Benchmarks

An initial performance test compares the timing for the steps: build, solve, re-build/update, re-solve. This is done for a standard model that is rebuilt from scratch each time vs. a parametric model. Timings (all values given in milliseconds) obtained from BenchmarkTools are listed below. All values correspond to median results. "Re-***" steps are only given for the parametric model, assuming that the standard would roughly be the same time in each iteration.

Percentage time increases/decreases for the total of the first iteration (timing it. #1) as well as all subsequent iterations (timing it. #i) are given at the end ("+" means the parametric model is slower, "-" means it's faster). T denotes the number of timesteps in the model; scroll to the right!.

build (standard) solve (standard) total (standard) build (parametric) solve (parametric) total (parametric) rebuild (parametric) resolve (parametric) "re"-total (parametric) timing it. jump-dev/ParametricOptInterface.jl#1 timing it. #i
T=100: 0.33 2.18 2.51 0.83 2.84 3.67 0.22 0.51 0.74 +46% -71%
T=1000: 1.87 21.85 23.71 2.87 32.16 35.03 2.83 4.79 7.63 +48% -68%
T=10000: 17.16 276.28 293.44 29.93 797.46 827.39 92.98 73.24 166.21 +172% -43%

While the overhead seems okay-ish for the first iteration (basically after 2 iterations we are already faster compared to rebuilding the model), a huge drop in performance can be seen for T=10000. I suspected some GC shenanigans, but compare:

# Build and pass a standard model to the solver
@benchmark optimize!(model_standard) setup=(
        p1 = get_params(1, 10000);
        model_standard=build_model(p1..., 10000; parametric=false);
        JuMP.set_time_limit_sec(model_standard, 1e-6)
    )
BenchmarkTools.Trial: 40 samples with 1 evaluation.
 Range (min … max):   96.336 ms … 113.824 ms  ┊ GC (min … max): 0.00% … 10.11%
 Time  (median):      99.600 ms               ┊ GC (median):    0.00%
 Time  (mean ± σ):   103.616 ms ±   6.278 ms  ┊ GC (mean ± σ):  4.26% ±  5.18%

        ▅▅ █ ▂                                                   
  ▅▁▅▁▁▁██▅████▅▅▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█▅▁▅████▅▁▅▁▁▅ ▁
  96.3 ms          Histogram: frequency by time          114 ms <

 Memory estimate: 32.43 MiB, allocs estimate: 220351.

# Build and pass a parametric model to the solver
@benchmark optimize!(model_parametric) setup=(
        p1 = get_params(1, 10000);
        model_parametric=build_model(p1..., 10000; parametric=true);
        JuMP.set_time_limit_sec(model_parametric, 1e-6)
    )
BenchmarkTools.Trial: 8 samples with 1 evaluation.
 Range (min … max):  622.821 ms … 671.644 ms  ┊ GC (min … max): 0.00% … 1.98%
 Time  (median):     642.528 ms               ┊ GC (median):    1.84%
 Time  (mean ± σ):   648.696 ms ±  16.536 ms  ┊ GC (mean ± σ):  1.49% ± 0.94%

  █                    ███ █                        █   █     █  
  █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███▁█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█▁▁▁█▁▁▁▁▁█ ▁
  623 ms           Histogram: frequency by time          672 ms <

 Memory estimate: 67.94 MiB, allocs estimate: 730572.

Memory usage and allocs for smaller models are basically the same ratio (~ 3:1), but I assume that some "non-model-size" related constant term seems to dominate the total time for "build+pass" there.

Notes

This is just a collection of notes/thoughts:

  • It still does not work with relax_with_penalty!(...)
  • Timings could be different for direct_model(...) (I'll check that later, didn't have enough time right now)

@odow
Copy link
Member

odow commented Feb 9, 2023

This is mostly my fault, but the format check failed: https://github.com/jump-dev/MathOptInterface.jl/actions/runs/4136044676/jobs/7151476121.

You can either follow the changes in the link, or do

cd("~/.julia/dev/MathOptInterface")  # or whatever the path is
using JuliaFormatter
format("src")
format("test")

I don't know if I fully understand the benchmarks, or the drop in performance as T grows. I need to take a deeper look.

cc @joaquimg @guilhermebodin @jd-lara who will all be interested in this.

@odow
Copy link
Member

odow commented Feb 9, 2023

It still does not work with relax_with_penalty!(...)

I'm not sure I understand this part. Why not? Was an error thrown? One thing the bridge can do is add support for MOI.modify, then that might work. See, e.g.,

function MOI.modify(
model::MOI.ModelLike,
bridge::FlipSignBridge,
change::MOI.ScalarCoefficientChange,
)
MOI.modify(
model,
bridge.constraint,
MOI.ScalarCoefficientChange(change.variable, -change.new_coefficient),
)
return
end

@sstroemer
Copy link
Author

sstroemer commented Feb 9, 2023

Formatting should be correct now.


I don't know if I fully understand the benchmarks, or the drop in performance as T grows. I need to take a deeper look.

I simplified the test model to what is actually necessary, and did additional tests. Full code at the end of this comment. New benchmark below

I'm not sure I understand this part. Why not? Was an error thrown? One thing the bridge can do is add support for MOI.modify, then that might work.

It "corrupted" the model resulting either in an infeasible model or one without a proper objective. I'm not sure - could be due to the way I was modifying the objective, ... . I've left relaxation out for now - will test that again + look at the MOI.modify if the rest looks like it's worth it!

Benchmark

Thoughts on what to benchmark: There are two different timings we care about; that of the first iteration, and those of all subsequent iterations (the third should already be equal to the second). While it may be a relevant advantage in a real case scenario, that subsequent solves are faster when using a parametric approach (compared to throwing away the first iteration's solution; since the solver could use some information for a warm start / ...), this is not a relevant timing to be measured (since it would essentially be the same for every parametric / set_coefficient / set warm start / ... approach). Therefore I have excluded solve times from now on (by using set_time_limit_sec(model, 1e-6) which forces the solver to immediately abort - but makes sure that the whole "JuMP passing to solver" part is finished).

The model looks like

$$ \begin{flalign} & I := \{1, ..., N\} & \\ && \\ & min \sum_{i \in I} x_i & \\ & s.t. & \\ & p_i \cdot x_i \geq b_i \qquad \forall i \in I & \end{flalign} $$

Here $p$ and $b$ are parameters, $p$ is the one being replaced by the bridge, while $b$ remains as fixed variable (being dropped during presolve). I have ommited using a parameter in the objective function, since that does not involve the bridge anyways. This is a worst case test, since every constraint in the model needs to be "touched" by the bridge on an update.

Performance seems to be: bad for the first iteration (standard and parametric seem to be O(N) but with clearly different slopes); almost identical for the subsequent iterations (the update is slightly faster):

build_and_pass

Timings (in milliseconds) are:

N standard parametric 1st iter. paramatric 2nd iter.
10 0.3 0.7 0.1
100 0.5 1.5 0.2
500 1.5 4.7 1.1
1000 2.7 8.7 2.2
2500 6.4 22.9 5.7
5000 12.3 51.9 11.8
7500 20.1 86.0 19.1
10000 25.0 118.8 25.0
15000 40.4 188.9 40.0
25000 76.8 409.7 70.8
50000 153.7 1095.2 151.1

However, there is still a "breakpoint" iteration after which the parametric version is faster (regarding the overall time of first iteration + all subsequent iterations; except for $N=10000$, but that is due to not enough sample in @benchmark).

Benchmark Code

Expand

Dependencies (dev MathOptInterface on the current changes):

[deps]
BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b"
JuMP = "4076af6c-e467-56ae-b986-b466b2749572"
MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee"
Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
using JuMP
import HiGHS
using BenchmarkTools
using Plots


function build_and_pass(N::Int64; parametric=false)
    """Build and pass a model to the solver."""
    model = Model(HiGHS.Optimizer)
    set_string_names_on_creation(model, false)
    set_silent(model)
    set_time_limit_sec(model, 1e-6)
    parametric && add_bridge(model, MOI.Bridges.Constraint.FixParametricVariablesBridge)

    @variable(model, x[1:N])
    if parametric
        @variable(model, p[1:N]); fix.(p, ones(N); force=true)
        @variable(model, b[1:N]); fix.(b, ones(N); force=true)
        @constraint(model, p .* x .>= b)
    else
        p = ones(N)
        b = ones(N)
        @constraint(model, p .* x .>= b)
    end

    @objective(model, Min, sum(x))
    optimize!(model)

    return nothing
end

function prepare(N::Int64)
    """Build a model and solve it."""
    model = Model(HiGHS.Optimizer)
    set_string_names_on_creation(model, false)
    set_silent(model)
    add_bridge(model, MOI.Bridges.Constraint.FixParametricVariablesBridge)

    @variable(model, x[1:N])
    @variable(model, p[1:N]); fix.(p, ones(N); force=true)
    @variable(model, b[1:N]); fix.(b, ones(N); force=true)
    @constraint(model, p .* x .>= b)

    @objective(model, Min, sum(x))
    optimize!(model)
    
    return model
end

function update_and_pass(model::JuMP.Model)
    """Take a model that was solved once, update it and pass it to the solver."""
    N = length(model[:x])
    fix.(model[:p], 2 .* ones(N); force=true)
    fix.(model[:b], 10 .* ones(N); force=true)

    set_time_limit_sec(model, 1e-6)
    optimize!(model)

    return nothing
end

sizes = [10, 100, 500, 1000, 2500, 5000, 7500, 10000, 15000, 25000, 50000]
timings = []
for N in sizes
    time_standard = median(@benchmark build_and_pass($N; parametric=false)).time
    time_parametric = median(@benchmark build_and_pass($N; parametric=true)).time
    time_rebuild = median(@benchmark update_and_pass(_m) setup=(_m=prepare($N))).time

    push!(timings, (
        standard=time_standard/1e6,
        parametric=time_parametric/1e6,
        rebuild=time_rebuild/1e6,
    ))
end

plot(
    sizes,
    [[it.standard for it in timings] [it.parametric for it in timings] [it.rebuild for it in timings]],
    title="build & pass",
    label=["standard" "parametric (1st iter.)" "parametric (2nd iter.)"], xlabel="N", ylabel="ms"
)
savefig("build_and_pass.png")

println(timings)

println("Thresholds:")
for i in eachindex(sizes)
    t = timings[i]
    n = ceil((t.standard - t.parametric) / (t.rebuild - t.standard))
    println("$(sizes[i]): \ti >= $n")
end

@sstroemer
Copy link
Author

sstroemer commented Feb 9, 2023

I have now done a "soft" test, that does not assume that each constraint contains a (bridged) parameter. The following constraints are added to the model (just to add some "arbitrary" non-parametric effort):

$$ \begin{flalign} b_i + x_i \geq 1 \qquad & \forall i \in I &\\ x_i \leq 10 \qquad &\forall i \in I &\\ x_i \geq 5 \qquad &\forall i \in \{i \in I : mod(i, 2) == 0)\} &\\ x_i \geq 0 \qquad &\forall i \in \{i \in I : mod(i, 2) == 1)\} &\\ \end{flalign} $$

This adds 3 new constraints (the last 2 are just a single one), which means that now 25% (1 out of 4) constraints contain a multiplicative parameter and need the bridge. The timing plot looks like:

build_and_pass

Now, at least up to $N = 25000$, at worst 10 iterations are enough to break even (meaning that the performance gains during iterations 2+ out do the losses in iteration 1), since the update gets faster.

However, I'm still unsure about the first iteration performance - for small models it's probably fine, but for larger ones (and for some use cases the $N=50000$ case is still an "extremely tiny" model) it feels unreasonable.

@odow
Copy link
Member

odow commented Feb 9, 2023

I think I need to profile this properly to see where the issue is. (If you haven't used it before, https://github.com/timholy/ProfileView.jl is the way to go.)

There's a few potential causes:

  • Rebuilding the dictionaries every final_touch. We could cache them in the bridge
  • Having a large number of bridges in the model, irrespective of the fact it's this particular bridge
  • Some issue unrelated performance issue with final_touch and a large number of bridges

We should also think about our aims:

  • It seems the biggest reason is to simplify the user experience. We don't want them rebuilding the model, and manually modifying constraints is a pain.
  • We're typically not going to improve solver performance because modifying the constraint matrix invalidates a lot of previous work that the solver did, so they often restart from scratch anyway. (Compare to changing the RHS vector, which can be efficiently warm-started from a previous basis.)

@sstroemer
Copy link
Author

I think I need to profile this properly to see where the issue is. (If you haven't used it before, https://github.com/timholy/ProfileView.jl is the way to go.)

I haven't profiled it yet - but I'll make sure to do that on the weekend!

Rebuilding the dictionaries every final_touch. We could cache them in the bridge

I'll add that. On a similar note: Is there an easy way to detect if bridge.f (the original MOI.ScalarQuadraticFunction) was modified? (to catch and warn the user if they try to modify the constraint manually, which would lead to problems)

Having a large number of bridges in the model, irrespective of the fact it's this particular bridge

Is there a way to prevent the other bridges, while still loading the parameter one? Using add_bridges=false also prevents using add_bridge(...) later on. The only way I see would be using FixParametricVariables manually (the SingleBridgeOptimizer wrapper).


It seems the biggest reason is to simplify the user experience. We don't want them rebuilding the model, and manually modifying constraints is a pain.

At least for me, yes. While rebuilding the model is probably the easiest thing to do most of the time, it wastes an unnecessary amount of time. Therefore the total time over some number of iterations should be considerably better using the parametric approach (otherwise people will stick to rebuilding) - while also making sure iteration 1 is actually doable (e.g. not "exploding" the memory usage too much)

We're typically not going to improve solver performance because modifying the constraint matrix invalidates a lot of previous work that the solver did, so they often restart from scratch anyway.

Yes.

@odow
Copy link
Member

odow commented Feb 9, 2023

I don't really understand the benchmark results. Most of the time spent is in HiGHS calling changeCoeff, but if you call it manually, the time isn't there. So something funky is going on. I need to dig in a little more.

(These numbers include a small change to cache the dictionaries so that we don't rebuild them every time.)

Code

using JuMP
import HiGHS

function build_and_pass(N::Int64, opt; manual::Bool)
    model = Model(opt)
    set_silent(model)
    set_string_names_on_creation(model, false)
    add_bridge(model, MOI.Bridges.Constraint.FixParametricVariablesBridge)
    @variable(model, x[1:N])
    @variable(model, p[1:N])
    fix.(p, 1.5; force = true)
    if manual
        @constraint(model, c, x .>= 1)
        set_normalized_coefficient.(c, x, 1.5)
    else
        @constraint(model, c, p .* x .>= 1)
    end
    @objective(model, Min, sum(x))
    @time optimize!(model)
    fix.(p, 2.0; force = true)
    if manual
        set_normalized_coefficient.(c, x, 2.0)
    end
    @time optimize!(model)
    return
end

import ProfileView
ProfileView.@profview build_and_pass(50_000, HiGHS.Optimizer; manual = false)
ProfileView.@profview build_and_pass(50_000, HiGHS.Optimizer; manual = true)

Output

julia> ProfileView.@profview build_and_pass(50_000, HiGHS.Optimizer; manual = false)
  1.583368 seconds (4.05 M allocations: 193.149 MiB, 5.90% gc time)
  0.084514 seconds (700.01 k allocations: 10.681 MiB)

julia> ProfileView.@profview build_and_pass(50_000, HiGHS.Optimizer; manual = true)
  0.540596 seconds (1.75 M allocations: 89.918 MiB, 7.51% gc time)
  0.024224 seconds (2 allocations: 32 bytes)

manual = false

image

manual = true

image

@odow
Copy link
Member

odow commented Feb 9, 2023

Ah. Once cause could be that the initial pass doesn't insert values into the constraint matrix. Then modifying the coefficient value later changes from 0 to 1, requiring a shift in the sparsity pattern of the constraint matrix. We could work around this by setting the affine part for all variables that appear. Let me have a go at a few modifications.

@odow
Copy link
Member

odow commented Feb 9, 2023

I made some initial changes here: sstroemer#1. They haven't changed the runtime performance much, but I did catch a bug handling x * x if x is fixed.

@odow
Copy link
Member

odow commented Feb 10, 2023

I swear those changes didn't make a difference. But I can back after a break, and found:

using JuMP
import HiGHS

function build_and_pass(N::Int64, opt; manual::Bool)
    @info "Build model"
    @time begin
        model = Model(opt)
        set_silent(model)
        set_string_names_on_creation(model, false)
        add_bridge(model, MOI.Bridges.Constraint.FixParametricVariablesBridge)
        @variable(model, x[1:N])
        @variable(model, p[1:N])
        fix.(p, 1.5; force = true)
        if manual
            @constraint(model, c, x .>= 1)
            set_normalized_coefficient.(c, x, 1.5)
        else
            @constraint(model, c, x .>= 1)
        end
        @objective(model, Min, sum(x))
    end
    @info "First optimize"
    @time optimize!(model)
    @info "Update"
    @time if manual
        fix.(p, 2.0; force = true)
        set_normalized_coefficient.(c, x, 2.0)
    else
        fix.(p, 2.0; force = true)
    end
    @info "Re-optimize"
    @time optimize!(model)
    @info "Total"
    return model
end

@time build_and_pass(50_000, HiGHS.Optimizer; manual = false);
@time build_and_pass(50_000, HiGHS.Optimizer; manual = true);
julia> @time build_and_pass(50_000, HiGHS.Optimizer; manual = false);
[ Info: Build model
  0.135477 seconds (2.23 M allocations: 106.356 MiB, 37.81% gc time)
[ Info: First optimize
  0.435786 seconds (1.75 M allocations: 89.918 MiB)
[ Info: Update
  0.084833 seconds (300.00 k allocations: 5.341 MiB)
[ Info: Re-optimize
  0.011754 seconds (2 allocations: 32 bytes)
[ Info: Total
  0.669441 seconds (4.28 M allocations: 201.639 MiB, 7.65% gc time)

julia> @time build_and_pass(50_000, HiGHS.Optimizer; manual = true);
[ Info: Build model
  0.092192 seconds (2.33 M allocations: 108.645 MiB)
[ Info: First optimize
  0.696616 seconds (1.75 M allocations: 89.918 MiB, 33.52% gc time)
[ Info: Update
  0.128399 seconds (500.00 k allocations: 9.155 MiB)
[ Info: Re-optimize
  0.022884 seconds (2 allocations: 32 bytes)
[ Info: Total
  0.941419 seconds (4.58 M allocations: 207.742 MiB, 24.81% gc time)

julia> @time build_and_pass(50_000, HiGHS.Optimizer; manual = false);
[ Info: Build model
  0.275107 seconds (2.23 M allocations: 106.356 MiB, 68.17% gc time)
[ Info: First optimize
  0.433582 seconds (1.75 M allocations: 89.918 MiB)
[ Info: Update
  0.084287 seconds (300.00 k allocations: 5.341 MiB)
[ Info: Re-optimize
  0.011777 seconds (2 allocations: 32 bytes)
[ Info: Total
  0.806095 seconds (4.28 M allocations: 201.639 MiB, 23.27% gc time)

julia> @time build_and_pass(50_000, HiGHS.Optimizer; manual = true);
[ Info: Build model
  0.117296 seconds (2.33 M allocations: 108.645 MiB, 27.67% gc time)
[ Info: First optimize
  0.463744 seconds (1.75 M allocations: 89.918 MiB, 5.65% gc time)
[ Info: Update
  0.119186 seconds (500.00 k allocations: 9.155 MiB)
[ Info: Re-optimize
  0.022526 seconds (2 allocations: 32 bytes)
[ Info: Total
  0.724041 seconds (4.58 M allocations: 207.742 MiB, 8.10% gc time)

If I read those results correctly, there's very little difference between manual = true and manual = false, suggesting that we're close to what we could achieve manually? That's promising.

@sstroemer
Copy link
Author

I made some initial changes here: sstroemer#1. They haven't changed the runtime performance much, but I did catch a bug handling x * x if x is fixed.

Thanks for the changes!


I looked at your first profiling benchmark and the second with the timings - there are differences in the build_and_pass(...). I'm not sure if / which you did on purpose - so please check if I understood something wrong (changes indicated by comments); code below.

Note: I have kept the set_normalized_coefficient.(c, x, 1.5) for manual=true, but I assume most of the time one would directly insert the 1.5 in the constraint macro, instead of creating+modifying?

using JuMP
import HiGHS

function build_and_pass(N::Int64, opt; manual::Bool)
    @info "Build model"
    @time begin
        model = Model(opt)
        set_silent(model)
        set_string_names_on_creation(model, false)
        add_bridge(model, MOI.Bridges.Constraint.FixParametricVariablesBridge)
        @variable(model, x[1:N])
        # @variable(model, p[1:N])              # moved into the branch `manual=false`
        # fix.(p, 1.5; force = true)            # moved into the branch `manual=false`
        if manual
            @constraint(model, c, x .>= 1)
            set_normalized_coefficient.(c, x, 1.5)
        else
            @variable(model, p[1:N])            # <--
            fix.(p, 1.5; force = true)          # <--
            @constraint(model, c, p .* x .>= 1) # added `p .*`
        end
        @objective(model, Min, sum(x))
    end
    @info "First optimize"
    @time optimize!(model)
    @info "Update"
    @time if manual
        # fix.(p, 2.0; force = true)           # no `p` in `manual=true`
        set_normalized_coefficient.(c, x, 2.0)
    else
        fix.(p, 2.0; force = true)
    end
    @info "Re-optimize"
    @time optimize!(model)
    @info "Total"
    return model
end

GC.gc()
@time build_and_pass(50_000, HiGHS.Optimizer; manual = false);
GC.gc()
@time build_and_pass(50_000, HiGHS.Optimizer; manual = true);

With that, we are still left with a sizeable difference:

julia> @time build_and_pass(50_000, HiGHS.Optimizer; manual = false);
[ Info: Build model
  0.168728 seconds (2.05 M allocations: 163.493 MiB, 35.16% gc time)
[ Info: First optimize
  0.885333 seconds (1.95 M allocations: 146.583 MiB, 4.39% gc time)
[ Info: Update
  0.055777 seconds (150.00 k allocations: 3.052 MiB)
[ Info: Re-optimize
  0.092662 seconds (250.01 k allocations: 3.815 MiB)
[ Info: Total
  1.203645 seconds (4.40 M allocations: 316.973 MiB, 8.16% gc time)

julia> @time build_and_pass(50_000, HiGHS.Optimizer; manual = true);
[ Info: Build model
  0.072086 seconds (901.52 k allocations: 68.443 MiB)
[ Info: First optimize
  0.156815 seconds (400.20 k allocations: 39.966 MiB)
[ Info: Update
  0.042946 seconds (150.00 k allocations: 3.052 MiB)
[ Info: Re-optimize
  0.020261 seconds (2 allocations: 32 bytes)
[ Info: Total
  0.293478 seconds (1.45 M allocations: 111.491 MiB)

bridge::FixParametricVariablesBridge{T,S},
chg::MOI.ScalarCoefficientChange{T},
) where {T,S}
MOI.modify(model, bridge.affine_constraint, chg)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't this that this would work, the coefficient change should change the coefficient before we added the quadratic part. If final_touch was already called then we should add the coefficient here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't final touch need to be called after this anyway?

There are two parts:

  • We modify the existing .affine_constraint. This catches adding new variables, or modifying a variable that doesn't exist in the quadratic part
  • We modify bridge.f, so that the next time we call final_touch, it gets updated correctly.

s::S,
) where {T,S<:MOI.AbstractScalarSet}
affine = MOI.ScalarAffineFunction(f.affine_terms, f.constant)
ci = MOI.add_constraint(model, affine, s)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be more efficient to not add any constraint here and add it in final_touch when it is already modified ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can leave a TODO to keep this PR simple

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then we'd need a Union{Nothing,ConstraintIndex} in the type. I guess it's a trade-off that might improve the first solve time, but it wouldn't improve the re-solve time (which is more important for this bridge).

@odow
Copy link
Member

odow commented Feb 10, 2023

looked at your first profiling benchmark and the second with the timings - there are differences in the build_and_pass(...). I'm not sure if / which you did on purpose - so please check if I understood something wrong (changes indicated by comments); code below.

Ah. Sure. Now I remember what I intended to do.

  • Yes, there's a difference between the bridge and calling only set_normalized_coefficient
  • My benchmark tested the steps that the bridge is doing manually (not what the user would do).
  • Since the comparison is similar, it means that the performance difference is due to the solver receiving 2x the number of variables and having to fix bounds, and not some unrelated overhead in the bridge or MOI's bridging system.

I think that's bad news, because it means we can't close the gap unless we find a way to not add the p variables to the inner model (e.g., by creating a new MOI.Parameter() set).

The good news is that I think it means this PR can be merged once we fix @blegat's comments. At a minimum, it simplifies the syntax for users, and they can evaluate whether it is worth the performance trade-off. There's also a route forward to writing a better bridge if we decide to add explicit parameters to MOI.

@sstroemer
Copy link
Author

sstroemer commented Feb 11, 2023

The good news is that I think it means this PR can be merged once we fix @blegat's comments. At a minimum, it simplifies the syntax for users, and they can evaluate whether it is worth the performance trade-off. There's also a route forward to writing a better bridge if we decide to add explicit parameters to MOI.

I'll take care of working in the comments.

But I'm still trying to dig into it a little bit more, to fully understand whats going on behind the scenes.

Question 1: Why does the following example result in one call to bridge_constraint(...) but two consecutive calls to final_touch(...)?

using JuMP
import HiGHS

model = Model(HiGHS.Optimizer)
add_bridge(model, MOI.Bridges.Constraint.FixParametricVariablesBridge)

@variable(model, x)
@variable(model, p); fix(p, 1.0; force=true)

@constraint(model, p * x >= 1)
@constraint(model, x >= 0)

@objective(model, Min, x + 0)
optimize!(model)

Question 2: How much does the

ci = MOI.ConstraintIndex{MOI.VariableIndex,MOI.EqualTo{T}}(x.value)
MOI.get(model, MOI.ConstraintSet(), ci).value

lookup cost? Could it be benefitial to replace that by a dictionary lookup for the current parameter value?

@odow
Copy link
Member

odow commented Feb 11, 2023

Question 1: Why does the following example result in one call to bridge_constraint(...) but two consecutive calls to final_touch(...)?

See #2088, #2089

Question 2: How much does the

It should be minimal, but I guess it makes a difference. Note that we have to do this lookup at least every time we call final-touch.

@odow odow changed the title Bridge based parameters [Bridges] Add FixParametricVariablesBridge Feb 13, 2023
@odow odow marked this pull request as ready for review February 13, 2023 01:12
@jd-lara
Copy link
Contributor

jd-lara commented Feb 13, 2023

looked at your first profiling benchmark and the second with the timings - there are differences in the build_and_pass(...). I'm not sure if / which you did on purpose - so please check if I understood something wrong (changes indicated by comments); code below.

Ah. Sure. Now I remember what I intended to do.

  • Yes, there's a difference between the bridge and calling only set_normalized_coefficient

  • My benchmark tested the steps that the bridge is doing manually (not what the user would do).

  • Since the comparison is similar, it means that the performance difference is due to the solver receiving 2x the number of variables and having to fix bounds, and not some unrelated overhead in the bridge or MOI's bridging system.

I think that's bad news, because it means we can't close the gap unless we find a way to not add the p variables to the inner model (e.g., by creating a new MOI.Parameter() set).

The good news is that I think it means this PR can be merged once we fix @blegat's comments. At a minimum, it simplifies the syntax for users, and they can evaluate whether it is worth the performance trade-off. There's also a route forward to writing a better bridge if we decide to add explicit parameters to MOI.

The qualitative evaluation of good and bad news also depends on the problem size. What we have learned with really large models are: 1) the performance is solver dependent and now the presolve works, HiGHS isn't the best to assess the trade off. 2) with recurrent solves not rebuilding the model outweighs the additional variables except when the solver can't support more than 1e9 variables.

For left hand side parameters (parameters on b) there should still be a way to avoid making the updates a la ParameterJuMP allows without additional variables.

@joaquimg
Copy link
Member

How does this compare to ParametricOptInterface performance-wise?

We should at least run these benchmarks: https://github.com/jump-dev/ParametricOptInterface.jl/tree/master/benchmark

If there is a selection of benchmarks that are considered better, we should certainly add to the ones in POI.

@joaquimg
Copy link
Member

The good news is that I think it means this PR can be merged once we fix @blegat's comments.

We should benchmark thoroughly before merging.

Also,

I think that's bad news, because it means we can't close the gap unless we find a way to not add the p variables to the inner model (e.g., by creating a new MOI.Parameter() set).

This is very relevant.


MOI.Bridges.needs_final_touch(::FixParametricVariablesBridge) = true

function MOI.Bridges.final_touch(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there should be some comments in this function

@jd-lara
Copy link
Contributor

jd-lara commented Feb 13, 2023

It is great that you are taking the time to experiment with this. I am very interested in this theme.

The MOI approach is usually my favorite since it's typically more flexible and easier to integrate with other extensions.

I would like to see benchmarks between such proposals and the existing systems: ParametricOptInterface and ParameterJuMP.

They are designed to be almost seamless to integrate with JuMP, and improving them might be easier than proposing larger changes in MOI and JuMP themselves.

I'd suggest and offer to help with benchmarking different aspects of the approaches. Not only the solve time matter but as we learned with ParameterJuMP the build cost can become problematic.

@joaquimg
Copy link
Member

Many of the simple benchmarks in POI are purely build-time benchmarks. More examples there are very welcome

@sstroemer
Copy link
Author

It is great that you are taking the time to experiment with this. I am very interested in this theme.

The MOI approach is usually my favorite since it's typically more flexible and easier to integrate with other extensions.

I would like to see benchmarks between such proposals and the existing systems: ParametricOptInterface and ParameterJuMP.

They are designed to be almost seamless to integrate with JuMP, and improving them might be easier than proposing larger changes in MOI and JuMP themselves.

Note: I am unsure if/how this compares to ParameterJuMP.jl since it's documentation lists "sum two parameters", "multiply parameters by constants", "sum parameters and variables", "sum parameters and affine expressions" (not covering multiplicative parameters).


I have looked at ParametricOptInterface.jl and wasn't entirely sure how to combine the benchmarks properly with the current use case (since they mainly test build / update steps, and not the chain involving passing to the solver), without redoing the benders decomp. benchmark. I've therefore used the same simple 50_000 parameters + constraints model from above, resulting in the following code:

function build_and_pass_poi(N::Int64, opt)
    @info "Build model"
    @time begin
        model = direct_model(ParametricOptInterface.Optimizer(opt))
        set_silent(model)
        
        @variable(model, x[1:N])
        @variable(model, p[i=1:N] in ParametricOptInterface.Parameter(1.0))
        
        @constraint(model, c[i=1:N], (1.5 * p[i]) * x[i] >= 1)
        @objective(model, Min, sum(x))
    end
    @info "First optimize"
    @time optimize!(model)
    @info "Update"
    @time begin 
        MOI.set.(model, POI.ParameterValue(), p, 2.0)
    end
    @info "Re-optimize"
    @time optimize!(model)
    @info "Total"
    return model
end

Note that I have dropped using set_string_names_on_creation(model, false) (also in the tests for the current PRs), compare also jump-dev/JuMP.jl#3321. All runs with $N=50000$, using direct_model and Gurobi.

Remark: I am not sure how to use direct_model with the bridge based version properly (wrapping the optimizer into a SingelBridgeOptimizer?), so the results for this PR are given with a Model() (should be a bit faster in the end).

Results for POI:

[ Info: Build model
  0.313061 seconds (3.60 M allocations: 246.187 MiB, 15.85% gc time)
[ Info: First optimize
  0.186813 seconds (899.53 k allocations: 48.059 MiB, 38.88% gc time)
[ Info: Update
  0.003423 seconds (149.50 k allocations: 2.282 MiB)
[ Info: Re-optimize
  0.159643 seconds (949.51 k allocations: 50.347 MiB)
[ Info: Total
  0.663897 seconds (5.60 M allocations: 346.903 MiB, 18.41% gc time)

Results for jump-dev/JuMP.jl#3215

[ Info: Build model
  0.339702 seconds (4.05 M allocations: 273.907 MiB, 24.73% gc time)
[ Info: First optimize
  0.077952 seconds (499.53 k allocations: 20.211 MiB)
[ Info: Update
  0.010795 seconds (300.02 k allocations: 8.011 MiB)
[ Info: Re-optimize
  0.077839 seconds (549.52 k allocations: 21.737 MiB)
[ Info: Total
  0.507277 seconds (5.40 M allocations: 323.894 MiB, 16.56% gc time)

Results for the bridge-based version (this PR)

[ Info: Build model
  0.209974 seconds (2.95 M allocations: 222.713 MiB, 24.63% gc time)
[ Info: First optimize
  0.257879 seconds (700.39 k allocations: 74.984 MiB)
[ Info: Update
  0.028153 seconds (150.00 k allocations: 3.052 MiB)
[ Info: Re-optimize
  0.162633 seconds (60 allocations: 1.828 KiB)
[ Info: Total
  0.659490 seconds (3.80 M allocations: 300.777 MiB, 7.84% gc time)

BenchmarkTools results

For POI:

BenchmarkTools.Trial: 30 samples with 1 evaluation.
 Range (min  max):  479.372 ms  582.332 ms  ┊ GC (min  max): 15.22%  27.34%
 Time  (median):     535.092 ms               ┊ GC (median):    23.12%
 Time  (mean ± σ):   534.643 ms ±  19.614 ms  ┊ GC (mean ± σ):  22.56% ±  3.04%

                             ▁ ▄  █▁▄    ▁▁                      
  ▆▁▁▁▁▆▁▁▁▁▁▁▁▁▁▁▆▁▁▁▁▁▁▆▁▁▁█▆█▆▁███▆▁▁▁██▁▆▆▁▆▆▁▁▁▁▁▁▁▁▁▁▁▁▁▆ ▁
  479 ms           Histogram: frequency by time          582 ms <

 Memory estimate: 340.02 MiB, allocs estimate: 5348999.

For jump-dev/JuMP.jl#3215:

BenchmarkTools.Trial: 30 samples with 1 evaluation.
 Range (min  max):  398.501 ms  473.766 ms  ┊ GC (min  max): 15.43%  25.29%
 Time  (median):     449.538 ms               ┊ GC (median):    23.65%
 Time  (mean ± σ):   442.453 ms ±  18.666 ms  ┊ GC (mean ± σ):  22.76% ±  3.24%

     ▃             ▃       ▃         ▃     ██  ▃ ▃█              
  ▇▁▁█▁▁▁▁▁▁▁▁▁▁▁▁▁█▁▁▁▁▁▇▁█▇▁▁▁▁▁▁▁▇█▁▇▇▁▁██▁▁█▇██▁▁▁▇▁▁▁▁▁▁▁▇ ▁
  399 ms           Histogram: frequency by time          474 ms <

 Memory estimate: 316.26 MiB, allocs estimate: 5097227.

For the bridge-based version (this PR)

BenchmarkTools.Trial: 30 samples with 1 evaluation.
 Range (min … max):  553.613 ms … 642.473 ms  ┊ GC (min … max): 10.67% … 11.44%
 Time  (median):     600.334 ms               ┊ GC (median):    12.92%
 Time  (mean ± σ):   593.272 ms ±  27.137 ms  ┊ GC (mean ± σ):  13.52% ±  2.74%

  ▁ ▁▁ ▁▁ ▁█ █▁▁   ▁  ▁         ▁  ▁▁▁▁ ▁▁ ██  ▁ ▁         ▁▁ ▁  
  █▁██▁██▁██▁███▁▁▁█▁▁█▁▁▁▁▁▁▁▁▁█▁▁████▁██▁██▁▁█▁█▁▁▁▁▁▁▁▁▁██▁█ ▁
  554 ms           Histogram: frequency by time          642 ms <

 Memory estimate: 300.75 MiB, allocs estimate: 3802035.

Conclusion

The bridge based version and POI are pretty similar performance wise, with the bridge based version being better in terms of memory and slightly better build times. However, it is probably less easy to use and does not support everything that POI supports.

The "JuMP"-based version (that basically intercepts the build_constraint) is roughly 15% faster, outperforming on both initial build as well as update/resolve. Memory-wise it is similar to POI. But, I am aware that it is probably not a good fit for JuMP itself (and it still only works for rather special use cases). The PR was meant more as an additional idea (to not flood this one here). I'll close it tomorrow (if there's nothing against that) and will either see if I can contribute that to ParameterJuMP or if it makes sense to pursue it otherwise.

@odow
Copy link
Member

odow commented Feb 13, 2023

@sstroemer do you want to email me so we can set up a call and I can walk you through the lay of the land and the different options we've tried? [email protected].

@joaquimg
Copy link
Member

POI was not optimized for memory, might be possible to improve that.
Speed-wise, it might be possible gaining a few extra ms.

@joaquimg
Copy link
Member

see if I can contribute that to ParameterJuMP or if it makes sense to pursue it otherwise.

POI was planned as a replacement for ParameterJuMP, my ideal recommendation would be to contribute to POI.

Let me know if you and oscar figure something out, I can help with POI guidance if needed.

@joaquimg
Copy link
Member

joaquimg commented Feb 13, 2023

not covering multiplicative parameters

You are correct, ParameterJuMP does not compare as it does not cover multiplicative parameters,

Also, you don't want to you the benders benchmark, I meant this:

https://github.com/jump-dev/ParametricOptInterface.jl/blob/master/benchmark/run_benchmarks.jl

or this:

https://github.com/jump-dev/ParametricOptInterface.jl/blob/master/benchmark/run_benchmarks_jump.jl

We should have more complete end-to-end benchmarks indeed, like the one you used here.

@odow
Copy link
Member

odow commented Feb 15, 2023

I've renamed this to _FixParametricVariablesBridge. I'm in favor of merging now so we can more easily benchmark this, and to make developing related PRs smaller.

We can always remove later if it turns out not to be useful.

@odow
Copy link
Member

odow commented Feb 15, 2023

Although we could potentially wait for #2095 and then update this bridge to work only on Parameter and not EqualTo.

@odow
Copy link
Member

odow commented Feb 19, 2023

I've updated this to use the new Parameter set, which makes the reformulation more controllable because it'll mean that you can have parameter * (temporarily fixed variable) and ensure that parameter gets replaced and not the fixed variable.

@odow
Copy link
Member

odow commented Feb 22, 2023

Thoughts on what we should do with this?

@odow
Copy link
Member

odow commented Feb 23, 2023

Monthly developer call suggests we leave this open for now and work on simplifying ParametricOptInterface. (It's likely that we can use the code here to simplify what's in POI.) We can always merge this later, but it shouldn't be enabled by default.

@odow
Copy link
Member

odow commented Sep 13, 2023

I'm not sure where we landed on this, but this shouldn't be turned on by default, and it seems unlikely to get used very much if it's opt-in.

Since we discussed this PR, @joaquimg put a lot of improvements into POI:

So perhaps it's best if we close and focus our efforts on https://github.com/jump-dev/ParametricOptInterface.jl.

@odow
Copy link
Member

odow commented Sep 22, 2023

Closing as won't-merge.

@odow odow closed this Sep 22, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

5 participants