Skip to content

Warm-starting a re-solve

How to start each solve in a loop from the work the solve before it did. It suits a loop whose solves differ by a small step: a rolling horizon, a myopic pathway, a search that inches. The reference is Model.solve.

Keep the solver's progress

Build once, then solve each update with keep='progress':

import specsolve as sps

model = sps.build('dispatch.yaml', sources)
model.solve()
for numbers in steps:
    result = model.update(numbers).solve(keep='progress')
    print(result.kept)  # progress

kept says what the solve actually kept. The first solve of a model keeps 'nothing', because no work came before it. An update that moves a mask or a coordinate set also gives 'nothing': the columns change, so the model is loaded again (Model.update).

The default, keep='solver', reuses the loaded model and discards the work. The answer is the same under every keep; only the time changes.

Check that it pays

Run the loop once with each keep= and read the clock the package keeps:

for keep in ('solver', 'progress'):
    model = sps.build('dispatch.yaml', sources)
    for numbers in steps:
        assert model.update(numbers).solve(keep=keep).kept in {keep, 'nothing'}
    print(keep, model.diagnostics().seconds['solve'])

Take the faster one. 'nothing' on every iteration means each update moved a mask and the model was rebuilt, so the loop is paying for the build, not the solve.

keep='progress' can lose by an order of magnitude and win by a factor of two, so measure rather than guess. Over six updates on HiGHS (#815), carrying the solver's work cost 76.6 s against 4.3 s on a dispatch model whose presolve cracks the problem outright, an 18× loss, and 111.2 s against 213.9 s on a storage model whose cyclic recurrence presolve cannot crack, a 1.9× win. It pays where the model is hard for its solver's preprocessing and consecutive solves differ by a small step. The answer does not change either way: across both models the objectives agreed to 2e-15 relative. No solver option reaches the same thing; on both solvers that ship, an option asking for it did not produce it (#815).

Warm-start a sweep

solve_over takes the same keep=, and carries from one slice to the next:

axis = sps.EachWindow('hour', steps=24, lookahead=24, into='t')
sweep = sps.solve_over('horizon.yaml', sources, axis, keep='progress')

Under executor=, every slice is a first solve and keeps 'nothing' (running slices in parallel).

To start a sweep from an earlier one, pass that sweep as start=. Each slice starts from the earlier slice of its key, under an executor too:

monday = sps.load_archive('runs/monday.zip').sweep
tuesday = sps.solve_over('dispatch.yaml', tuesday_sources, axis, start=monday)

A table passed as start= is cut by the axis, as a source is. Write it over the sliced dimension, such as snapshot for EachWindow, and each window takes the rows of the coordinates it covers. To start each slice from the one before it instead, pass start='previous'. That runs only without an executor.

start= combines with keep='solver' only. keep='progress' and keep='nothing' also say what the solve begins from, so a solve given either beside start= is refused.

Time a cold solve

Pass keep='nothing'. It discards the held solver before the load, so no basis, incumbent or solver-internal state survives. A benchmark needs that, and so does comparing two sets of solver_options.

Start from an earlier answer

keep='progress' carries the work only while the model's structure stays the same. A cutting-plane master re-solved after gaining a cut has gained a row, and a rebuild loads a fresh solver. Start an LP from an earlier answer instead:

previous = master.solve(outputs={'basis'})
for cut in cuts:
    previous = master.update(cut).solve(start=previous, outputs={'basis'})

The engine lays the answer's basis onto the new build by coordinate. A coordinate both builds hold keeps its status. A variable only the new build holds starts at a bound, and a constraint only the new build holds starts not binding, so a cut enters without moving the vertex. The answer can be live, loaded with load_result or read from an archive, and it can come from another solver. An answer solved without outputs={'basis'} carries no basis, and an LP then starts from its values, as the next section describes.

It pays most where the model changes least. An unchanged model started from its own answer does no simplex work. Over a Benders run on the master of bench/warm_payoff.py, with HiGHS, a start from the previous master saved 66% to 73% of the simplex iterations at every size measured (#1877). Whether it saves time too depends on the model: on a small master, reading and matching the basis costs more than the iterations it saves. Measure before relying on it.

Start from values

A mixed-integer model starts from values: the solver takes them as its first incumbent, which bounds the search from the start. Pass an earlier answer, and its primal is the start:

yesterday = sps.load_result('runs/monday')
today = sps.solve('commitment.yaml', sources, start=yesterday)

Or pass values per variable under 'primal', from a heuristic or a rule of thumb, in any shape a parameter's source takes over the variable's dims: a table in the shape primal returns, a parquet path, a {label: value} map, or one number for every coordinate:

on = pl.DataFrame({'unit': ['coal', 'gas'], 'hour': [0, 0], 'value': [1.0, 1.0]})
result = sps.solve('commitment.yaml', sources, start={'primal': {'status': on}})
result = sps.solve('commitment.yaml', sources, start={'primal': {'status': 1.0}})

An LP takes values only where the solver uses them. HiGHS starts an LP from values that give every column one. Gurobi takes values for an LP, and the solve warns, because no gain from them is known. Xpress takes values for an LP only where they give every column one, and refuses values that leave a column out.

Start from a basis you give

A basis is a status per coordinate, so it can come from anywhere: a file, a rule, another tool. Pass a table per variable under 'variable_basis' and per constraint under 'constraint_basis', in the shape variable_basis returns, or a parquet path to one. The status is one of basic, at_lower, at_upper, fixed and superbasic:

loose = pl.DataFrame({'snapshot': [0, 1], 'value': ['basic', 'basic']})
result = sps.solve('dispatch.yaml', sources, start={'constraint_basis': {'line_limit': loose}})

A basis starts an LP only. Given both a basis and values, an LP starts from the basis, and a mixed-integer model from the values. The engine repairs a basis that does not fit: a status at a bound the variable lacks moves to one it has, and the count of basic entries is made one per constraint.

What a start may leave out

A start can name some declarations and some coordinates and leave out the rest. The solver fills in what is missing, and repairs what does not fit, as far as it can. A start is a hint: it never changes the optimum, only how soon a good solution is found. Each table is read and checked as a parameter's source is. The solve refuses, before the solver loads:

  • A key other than the three readers. start= takes 'primal', 'variable_basis' and 'constraint_basis'.
  • A table for a declaration the model lacks, or one a parameter over the same dims would be refused for: a missing column, a label the dimension lacks, a coordinate given twice.
  • A basis status outside the five words.
  • A basis alone for a mixed-integer model, which starts from values.
  • A start that lands on no coordinate the model holds.