Skip to content

Commit 9c2de91

Browse files
committed
Merge remote-tracking branch 'rael/wecc' into plots
2 parents b7c0d69 + d84a114 commit 9c2de91

File tree

6 files changed

+785
-258
lines changed

6 files changed

+785
-258
lines changed

docs/Performance.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Performance
2+
3+
Memory use and solve time are two important factors that we try to keep to a minimum in our models. There are multiple
4+
things one can do to improve performance.
5+
6+
## Solving methods
7+
8+
By far the biggest factor that impacts performance is the method used by Gurobi. The fastest method is barrier solve
9+
without crossover (use `--recommended-fast`)
10+
however this method often returns a suboptimal solution. The next fastest is barrier solve followed by crossover and
11+
simplex (use `--recommended`) which almost always works. In some cases barrier solve encounters numerical issues (
12+
see [`Numerical Issues.md`](./Numerical%20Issues.md))
13+
in which case the slower Simplex method must be used (`--solver-options-string method=1`).
14+
15+
## Solver interface
16+
17+
Solver interfaces are how Pyomo communicates with Gurobi (or any solver).
18+
19+
There are two solver interfaces that you should know about: `gurobi` and `gurobi_direct`.
20+
21+
- When using `gurobi`, Pyomo will write the entire model to a temporary text file and then start a *separate Gurobi
22+
process* that will read the file, solve the model and write the results to another temporary text file. Once Gurobi
23+
finishes writing the results Pyomo will read the results text file and load the results back into the Python program
24+
before running post_solve (e.g. generate csv files, create graphs, etc). Note that these temporary text files are
25+
stored in `/tmp` but if you use `--recommended-debug` Pyomo and Gurobi will instead use a `temp` folder in your model.
26+
27+
- `gurobi_direct` uses Gurobi's Python library to create and solve the model directly in Python without the use of
28+
intermediate text files.
29+
30+
In theory `gurobi_direct` should be faster and more efficient however in practice we find that that's not the case. As
31+
such we recommend using `gurobi` and all our defaults do so. If someone has the time they could profile `gurobi_direct`
32+
to improve performance at which point we could make `gurobi_direct` the default (and enable `--save-warm-start` by default, see below).
33+
34+
The `gurobi` interface has the added advantage of separating Gurobi and Pyomo into separate threads. This means that
35+
while Gurobi is solving and Pyomo is idle, the operating system can automatically move Pyomo's memory usage
36+
to [virtual memory](https://serverfault.com/questions/48486/what-is-swap-memory)
37+
which will free up more memory for Gurobi.
38+
39+
## Warm starting
40+
41+
Warm starting is the act of using a solution from a previous similar model to start the solver closer to your expected
42+
solution. Theoretically this can help performance however in practice there are several limitations. For this section, *
43+
previous solution* refers to the results from an already solved model that you are using to warm start the solver. *
44+
Current solution* refers to the solution you are trying to find while using the warm start feature.
45+
46+
- To warm start a model use `switch solve --warm-start <path_to_previous_solution>`.
47+
48+
- Warm starting only works if the previous solution does not break any constraints of the current solution. This usually
49+
only happens if a) the model has the exact same set of variables b)
50+
the previous solution was "harder" (e.g. it had more constraints to satisfy).
51+
52+
- Warm starting always uses the slower Simplex method. This means unless you expect the previous solution and current
53+
solution to be very similar, it may be faster to solve without warm start using the barrier method.
54+
55+
- If your previous solution didn't use crossover (e.g. you used `--recommended-fast`) then warm starting will be even
56+
slower since the solver will need to first run crossover before warm starting.
57+
58+
- Our implementation of warm starting only works if your previous solution has an `outputs/warm_start.pickle`
59+
file. This file is only generated when you use `--save-warm-start`.
60+
61+
- `--save-warm-start` and `--warm-start` both use an extension of the `gurobi_direct` solver interface which is
62+
generally slower than the `gurobi` solver interface (see section above).
63+
64+
## Tools for improving performance
65+
66+
- [Memory profiler](https://pypi.org/project/memory-profiler/) for generating plots of the memory
67+
use over time. Use `mprof run --interval 60 --multiprocess switch solve ...` and once solving is done
68+
run `mprof plot -o profile.png` to make the plot.
69+
70+
- [Fil Profiler](https://pypi.org/project/filprofiler/) is an amazing tool for seeing which parts of the code are
71+
using up memory during peak memory usage.
72+
73+
- Using `switch_model.utilities.StepTimer` to measure how long certain code blocks take to run. See examples
74+
throughout the code.

switch_model/solve.py

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from switch_model.tools.graph.cli_graph import main as graph_main
3636
from switch_model.utilities.patches import patch_pyomo
3737
from switch_model.utilities.results_info import save_info, add_info, ResultsInfoSection
38+
import switch_model.utilities.gurobi_aug # We keep this line here to ensure that 'gurobi_aug' gets registered as a solver
3839

3940

4041
def main(
@@ -155,9 +156,6 @@ def debug(type, value, tb):
155156
# create an instance (also reports time spent reading data and loading into model)
156157
instance = model.load_inputs(attach_data_portal=attach_data_portal)
157158

158-
if instance.options.warm_start:
159-
warm_start(instance)
160-
161159
#### Below here, we refer to instance instead of model ####
162160

163161
instance.pre_solve()
@@ -190,6 +188,13 @@ def debug(type, value, tb):
190188
# We no longer need model (only using instance) so we can garbage collect it to optimize our memory usage
191189
del model
192190

191+
if instance.options.warm_start_mip:
192+
if instance.options.verbose:
193+
timer.step_time()
194+
warm_start_mip(instance)
195+
if instance.options.verbose:
196+
print(f"Loaded warm start inputs in {timer.step_time_as_str()}.")
197+
193198
if instance.options.reload_prior_solution:
194199
print("Loading prior solution...")
195200
reload_prior_solution_from_pickle(instance, instance.options.outputs_dir)
@@ -276,14 +281,14 @@ def debug(type, value, tb):
276281
)
277282

278283

279-
def warm_start(instance):
284+
def warm_start_mip(instance):
280285
"""
281-
This function loads in the variables from a previous run
282-
and starts out our model at these variables to make it reach
283-
a solution faster.
286+
This function loads the results from a previous run into the Pyomo variables.
287+
This allows Gurobi's Mixed Integer Programming algorithm to "warm start" (start closer to the solution).
288+
Warm starting only works in Gurobi if the initial values don't violate any constraints
289+
(i.e. valid but not optimal solution).
284290
"""
285-
warm_start_timer = StepTimer()
286-
warm_start_dir = os.path.join(instance.options.warm_start, "outputs")
291+
warm_start_dir = os.path.join(instance.options.warm_start_mip, "outputs")
287292
if not os.path.isdir(warm_start_dir):
288293
warnings.warn(
289294
f"Path {warm_start_dir} does not exist and cannot be used to warm start solver. Warm start skipped."
@@ -310,8 +315,6 @@ def warm_start(instance):
310315
# If the index isn't valid that's ok, just don't warm start that variable
311316
pass
312317

313-
print(f"Loaded warm start inputs in {warm_start_timer.step_time_as_str()}.")
314-
315318

316319
def reload_prior_solution_from_pickle(instance, outdir):
317320
with open(os.path.join(outdir, "results.pickle"), "rb") as fh:
@@ -524,10 +527,16 @@ def define_arguments(argparser):
524527
# whether that does the same thing as --solver-options-string so we don't reuse the same name.
525528
argparser.add_argument(
526529
"--solver-options-string",
527-
default=None,
530+
default="",
528531
help="A quoted string of options to pass to the model solver. Each option must be of the form option=value. "
529532
"(e.g., --solver-options-string \"mipgap=0.001 primalopt='' advance=2 threads=1\")",
530533
)
534+
argparser.add_argument(
535+
"--solver-method",
536+
default=None,
537+
type=int,
538+
help="Specify the solver method to use.",
539+
)
531540
argparser.add_argument(
532541
"--keepfiles",
533542
action="store_true",
@@ -639,6 +648,12 @@ def define_arguments(argparser):
639648
action="store_true",
640649
help="Save the solution to a pickle file after model is solved to allow for later inspection via --reload-prior-solution.",
641650
)
651+
argparser.add_argument(
652+
"--save-warm-start",
653+
default=False,
654+
action="store_true",
655+
help="Save warm_start.pickle to the outputs which allows future runs to warm start from this one.",
656+
)
642657
argparser.add_argument(
643658
"--interact",
644659
default=False,
@@ -685,10 +700,27 @@ def define_arguments(argparser):
685700
help="Number of threads to be used while solving. Currently only supported for Gurobi",
686701
)
687702

703+
argparser.add_argument(
704+
"--warm-start-mip",
705+
default=None,
706+
help="Enables warm start for a Mixed Integer problem by specifying the "
707+
"path to a previous scenario. Warm starting only works if the solution to the previous solution"
708+
"is also a feasible (but not necessarily optimal) solution to the current scenario.",
709+
)
710+
688711
argparser.add_argument(
689712
"--warm-start",
690713
default=None,
691-
help="Path to folder of directory to use for warm start",
714+
help="Enables warm start for a LP Problem by specifying the path to the previous scenario. Note"
715+
" that all variables must be the same between the previous and current scenario.",
716+
)
717+
718+
argparser.add_argument(
719+
"--gurobi-make-mps",
720+
default=False,
721+
action="store_true",
722+
help="Instead of solving just output a Gurobi .mps file that can be used for debugging numerical properties."
723+
" See https://github.com/staadecker/lp-analyzer/ for details.",
692724
)
693725

694726

@@ -719,7 +751,7 @@ def add_recommended_args(argparser):
719751
"--recommended-debug",
720752
default=False,
721753
action="store_true",
722-
help='Equivalent to running with all of the following options: --solver gurobi -v --sorted-output --keepfiles --tempdir temp --stream-output --symbolic-solver-labels --log-run --debug --solver-options-string "method=2 BarHomogeneous=1 FeasibilityTol=1e-5"',
754+
help="Same as --recommended but adds the flags --keepfiles --tempdir temp --symbolic-solver-labels which are useful when debugging Gurobi.",
723755
)
724756

725757

@@ -748,8 +780,10 @@ def parse_recommended_args(args):
748780
"--log-run",
749781
"--debug",
750782
"--graph",
783+
"--solver-method",
784+
"2", # Method 2 is barrier solve which is much faster than default
751785
] + args
752-
solver_options_string = "BarHomogeneous=1 FeasibilityTol=1e-5 method=2"
786+
solver_options_string = "BarHomogeneous=1 FeasibilityTol=1e-5"
753787
if options.recommended_fast:
754788
solver_options_string += " crossover=0"
755789
args = ["--solver-options-string", solver_options_string] + args
@@ -851,6 +885,18 @@ def solve(model):
851885
solver = model.solver
852886
solver_manager = model.solver_manager
853887
else:
888+
# If we need warm start switch the solver to our augmented version that supports warm starting
889+
if model.options.warm_start is not None or model.options.save_warm_start:
890+
if model.options.solver not in ("gurobi", "gurobi_direct"):
891+
raise NotImplementedError(
892+
"Warm start functionality requires --solver gurobi"
893+
)
894+
model.options.solver = "gurobi_aug"
895+
896+
if model.options.warm_start is not None:
897+
# Method 1 (dual simplex) is required since it supports warm starting.
898+
model.options.solver_method = 1
899+
854900
# Create a solver object the first time in. We don't do this until a solve is
855901
# requested, because sometimes a different solve function may be used,
856902
# with its own solver object (e.g., with runph or a parallel solver server).
@@ -860,28 +906,35 @@ def solve(model):
860906
# Note previously solver was saved in model however this is very memory inefficient.
861907
solver = SolverFactory(model.options.solver, solver_io=model.options.solver_io)
862908

863-
# If this option is enabled, gurobi will output an IIS to outputs\iis.ilp.
864-
if model.options.gurobi_find_iis:
865-
# Enable symbolic labels since otherwise we can't debug the .ilp file.
866-
model.options.symbolic_solver_labels = True
909+
if model.options.gurobi_find_iis and model.options.gurobi_make_mps:
910+
raise Exception("Can't use --gurobi-find-iis with --gurobi-make-mps.")
867911

868-
# If no string is passed make the string empty so we can add to it
869-
if model.options.solver_options_string is None:
870-
model.options.solver_options_string = ""
912+
if model.options.gurobi_find_iis or model.options.gurobi_make_mps:
913+
# If we are outputting a file we want to enable symbolic labels to help debugging
914+
model.options.symbolic_solver_labels = True
871915

916+
# If this option is enabled, gurobi will output an IIS to outputs\iis.ilp.
917+
if model.options.gurobi_find_iis:
872918
# Add to the solver options 'ResultFile=iis.ilp'
873919
# https://stackoverflow.com/a/51994135/5864903
874-
iis_file_path = os.path.join(model.options.outputs_dir, "iis.ilp")
875-
model.options.solver_options_string += " ResultFile={}".format(
876-
iis_file_path
920+
model.options.solver_options_string += " ResultFile=iis.ilp"
921+
if model.options.gurobi_make_mps:
922+
# Output the input file and set time limit to zero to ensure it doesn't actually solve
923+
model.options.solver_options_string += (
924+
f" ResultFile=problem.mps TimeLimit=0"
877925
)
878926

879927
if model.options.threads:
928+
model.options.solver_options_string += f" Threads={model.options.threads}"
929+
930+
if model.options.solver_method is not None:
880931
# If no string is passed make the string empty so we can add to it
881932
if model.options.solver_options_string is None:
882933
model.options.solver_options_string = ""
883934

884-
model.options.solver_options_string += f" Threads={model.options.threads}"
935+
model.options.solver_options_string += (
936+
f" method={model.options.solver_method}"
937+
)
885938

886939
solver_manager = SolverManagerFactory(model.options.solver_manager)
887940

@@ -896,9 +949,17 @@ def solve(model):
896949
else None,
897950
)
898951

899-
if model.options.warm_start is not None:
952+
if model.options.warm_start_mip is not None or model.options.warm_start is not None:
900953
solver_args["warmstart"] = True
901954

955+
if model.options.warm_start is not None:
956+
solver_args["read_warm_start"] = os.path.join(
957+
model.options.warm_start, "outputs", "warm_start.pickle"
958+
)
959+
960+
if model.options.save_warm_start:
961+
solver_args["write_warm_start"] = os.path.join("outputs", "warm_start.pickle")
962+
902963
# drop all the unspecified options
903964
solver_args = {k: v for (k, v) in solver_args.items() if v is not None}
904965

0 commit comments

Comments
 (0)