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

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:

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

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 to start from must carry both basis outputs. It can be live, loaded with load_result or read from an archive, and it can come from another solver.

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 a mixed-integer solve from values

A mixed-integer model has no basis to start from, so start= gives it values instead. 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, 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={'status': on})
result = sps.solve('commitment.yaml', sources, start={'status': 1.0})

A start can name some variables 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. Values are read and checked as a parameter's source is, so a label the dimension lacks or a coordinate given twice is refused. So is a name that is no variable, a start that places no value at all, and values for an LP, which starts from a basis.