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.
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)
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.
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.
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
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,
)