Improve computational performance

SDDP is a computationally intensive algorithm. Here are some suggestions for how the computational performance can be improved.

Numerical stability (again)

We've already discussed this in the Numerical stability section of Words of warning. But, it's so important that we're going to say it again: improving the problem scaling is one of the best ways to improve the numerical performance of your models.

Solver selection

The majority of the solution time is spent inside the low-level solvers. Choosing which solver (and the associated settings) correctly can lead to big speed-ups.

  • Choose a commercial solver.

    Options include CPLEX, Gurobi, and Xpress. Using free solvers such as CLP and HiGHS isn't a viable approach for large problems.

  • Try different solvers.

Even commercial solvers can have wildly different solution times. We've seen models on which CPLEX was 50% fast than Gurobi, and vice versa.

  • Experiment with different solver options.

    Using the default settings is usually a good option. However, sometimes it can pay to change these. In particular, forcing solvers to use the dual simplex algorithm (e.g., Method=1 in Gurobi ) is usually a performance win.

Single-cut vs. multi-cut

There are two competing ways that cuts can be created in SDDP: single-cut and multi-cut. By default, SDDP.jl uses the single-cut version of SDDP.

The performance of each method is problem-dependent. We recommend that you try both in order to see which one performs better. In general, the single-cut method works better when the number of realizations of the stagewise-independent random variable is large, whereas the multi-cut method works better on small problems. However, the multi-cut method can cause numerical stability problems, particularly if used in conjunction with objective or belief state variables.

You can switch between the methods by passing the relevant flag to cut_type in SDDP.train.

SDDP.train(model; cut_type = SDDP.SINGLE_CUT)
SDDP.train(model; cut_type = SDDP.MULTI_CUT)

Parallelism

SDDP.jl can take advantage of the parallel nature of modern computers to solve problems across multiple cores.

Info

We highly recommend that you read the Julia manual's section on parallel computing.

You can start Julia from a command line with N processors using the -p flag:

julia -p N

Alternatively, you can use the Distributed.jl package:

using Distributed
Distributed.addprocs(N)
Warning

Workers DON'T inherit their parent's Pkg environment. Therefore, if you started Julia with --project=/path/to/environment (or if you activated an environment from the REPL), you will need to put the following at the top of your script:

using Distributed
@everywhere begin
    import Pkg
    Pkg.activate("/path/to/environment")
end

Currently SDDP.jl supports to parallel schemes, SDDP.Serial and SDDP.Asynchronous. Instances of these parallel schemes should be passed to the parallel_scheme argument of SDDP.train and SDDP.simulate.

using SDDP, HiGHS
model = SDDP.LinearPolicyGraph(
  stages = 2, lower_bound = 0, optimizer = HiGHS.Optimizer
) do sp, t
     @variable(sp, x >= 0, SDDP.State, initial_value = 1)
     @stageobjective(sp, x.out)
end
SDDP.train(model; iteration_limit = 10, parallel_scheme = SDDP.Asynchronous())
SDDP.simulate(model, 10; parallel_scheme = SDDP.Asynchronous())

There is a large overhead for using the asynchronous solver. Even if you choose asynchronous mode, SDDP.jl will start in serial mode while the initialization takes place. Therefore, in the log you will see that the initial iterations take place on the master thread (Proc. ID = 1), and it is only after while that the solve switches to full parallelism.

Info

Because of the large data communication requirements (all cuts have to be shared with all other cores), the solution time will not scale linearly with the number of cores.

Info

Given the same number of iterations, the policy obtained from asynchronous mode will be worse than the policy obtained from serial mode. However, the asynchronous solver can take significantly less time to compute the same number of iterations.

Data movement

By default, data defined on the master process is not made available to the workers. Therefore, a model like the following:

data = 1
model = SDDP.LinearPolicyGraph(stages = 2, lower_bound = 0) do sp, t
     @variable(sp, x >= 0, SDDP.State, initial_value = data)
     @stageobjective(sp, x.out)
end

will result in an UndefVarError error like UndefVarError: data not defined.

There are three solutions for this problem.

Option 1: declare data inside the build function

model = SDDP.LinearPolicyGraph(stages = 2) do sp, t
    data = 1
    @variable(sp, x >= 0, SDDP.State, initial_value = 1)
    @stageobjective(sp, x)
end

Option 2: use @everywhere

@everywhere begin
    data = 1
end
model = SDDP.LinearPolicyGraph(stages = 2) do sp, t
    @variable(sp, x >= 0, SDDP.State, initial_value = 1)
    @stageobjective(sp, x)
end

Option 3: build the model in a function

function build_model()
    data = 1
    return SDDP.LinearPolicyGraph(stages = 2) do sp, t
        @variable(sp, x >= 0, SDDP.State, initial_value = 1)
        @stageobjective(sp, x)
    end
end

model = build_model()

Initialization hooks

Warning

This is important if you use Gurobi!

SDDP.Asynchronous accepts a pre-processing hook that is run on each worker process before the model is solved. The most useful situation is for solvers than need an initialization step. A good example is Gurobi, which can share an environment amongst all models on a worker. Notably, this environment cannot be shared amongst workers, so defining one environment at the top of a script will fail!

To initialize a new environment on each worker, use the following:

SDDP.train(
    model;
    parallel_scheme = SDDP.Asynchronous() do m::SDDP.PolicyGraph
        env = Gurobi.Env()
        set_optimizer(m, () -> Gurobi.Optimizer(env))
    end,
)