Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ Arc costs are closures, so distances can come from coordinates, a precomputed
matrix or a live routing service. The solver itself never sees a coordinate.

`solve_with` takes the same arguments plus a callback, if you want to watch the
search progress. `search_log()` is a ready made one that prints to stderr.
search progress. `search_log()` is a ready made one that prints to stderr. The
callback can also stop the search: return `ControlFlow::Break(())` and the
solver hands back the best solution so far. The
[`early_stop`](examples/early_stop.rs) example stops once the search stalls.

Full API docs with `cargo doc --open`.

Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ cargo run --release --example <name>
| [`forbidden_nodes`](forbidden_nodes.rs) | Block a vehicle from serving a customer with `forbid`, and see the solver route around it |
| [`precedence`](precedence.rs) | Order two stops within a route with `precede`, and see a pair split across vehicles go unordered |
| [`time_windows`](time_windows.rs) | Serve each stop inside its window with `cumul_bounds`, price idle time with `wait_cost`, and read the timetable back with `walk_route` |
| [`early_stop`](early_stop.rs) | Stop a guided search from the `solve_with` callback once it finds no gain of 1% or more for a few seconds |
61 changes: 61 additions & 0 deletions examples/early_stop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! Stop a long guided search once it stalls. Run with:
//!
//! ```sh
//! cargo run --release --example early_stop
//! ```
//!
//! The callback handed to `solve_with` sees every `SearchEvent` and can end
//! the search by returning `ControlFlow::Break(())`. The rule below is one
//! choice among many: stop when no improvement of at least `MIN_GAIN_PCT`
//! has landed for `PATIENCE`. Set `MIN_GAIN_PCT` to 0 to count any
//! improvement at all.

use std::fs;
use std::ops::ControlFlow;
use std::path::Path;
use std::time::{Duration, Instant};

use volare::cvrplib::{Instance, cvrp_model};
use volare::{Construct, Cost, Improve, SearchEvent, search_log, solve_with};

const PATIENCE: Duration = Duration::from_secs(2);
const MIN_GAIN_PCT: Cost = 1;

fn main() {
let vrp = Path::new(env!("CARGO_MANIFEST_DIR")).join("instances/X-n101-k25.vrp");
let inst = Instance::parse(&fs::read_to_string(&vrp).unwrap());
let model = cvrp_model(&inst, inst.coords.len() - 1);

let started = Instant::now();
let mut log = search_log();
// Time and cost of the last improvement big enough to count.
let mut mark: Option<(Instant, Cost)> = None;
let monitor = |event| {
let _ = log(event);
if let SearchEvent::GuidedBest { cost, .. } = event {
match mark {
Some((_, at)) if cost * 100 > at * (100 - MIN_GAIN_PCT) => {}
_ => mark = Some((Instant::now(), cost)),
}
}
match mark {
Some((at, _)) if at.elapsed() > PATIENCE => ControlFlow::Break(()),
_ => ControlFlow::Continue(()),
}
};

// No round limit: the monitor alone decides when to stop.
let sol = solve_with(
&model,
Construct::CheapestInsertion,
Improve::Gls { iters: usize::MAX },
monitor,
);

println!(
"stopped after {:.1}s: no gain of {MIN_GAIN_PCT}% or more in {}s",
started.elapsed().as_secs_f64(),
PATIENCE.as_secs(),
);
println!("total cost: {}", sol.cost);
}
15 changes: 11 additions & 4 deletions src/bin/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

use std::collections::HashMap;
use std::fs;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use std::time::Instant;

Expand Down Expand Up @@ -145,7 +146,9 @@ fn bench_reference(
seed: start as u64,
k: if restarts == 1 { 1 } else { rcl },
};
let mut sol = first_solution_with(&model, construct, &mut log);
let mut sol = first_solution_with(&model, construct, |e| {
let _ = log(e);
});
let ctor = eval_routes(&model, &sol).expect("infeasible construction");
match gls {
Some(iters) => guided_local_search_with(&model, &mut sol, iters, &mut log),
Expand Down Expand Up @@ -466,19 +469,23 @@ fn free_fleet(inst: &Instance) -> usize {
inst.coords.len() - 1
}

fn solve(m: &Model, gls: Option<usize>, log: &mut dyn FnMut(SearchEvent)) -> Solution {
fn solve(
m: &Model,
gls: Option<usize>,
log: &mut dyn FnMut(SearchEvent) -> ControlFlow<()>,
) -> Solution {
let improve = match gls {
Some(iters) => Improve::Gls { iters },
None => Improve::HillClimb,
};
solve_with(m, Construct::CheapestInsertion, improve, log)
}

fn logger(on: bool) -> Box<dyn FnMut(SearchEvent)> {
fn logger(on: bool) -> Box<dyn FnMut(SearchEvent) -> ControlFlow<()>> {
if on {
Box::new(search_log())
} else {
Box::new(|_| {})
Box::new(|_| ControlFlow::Continue(()))
}
}

Expand Down
52 changes: 37 additions & 15 deletions src/solver.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::ops::ControlFlow;
use std::time::Instant;

use crate::eval::{Routes, eval_routes};
Expand Down Expand Up @@ -38,9 +39,16 @@ impl std::fmt::Display for Operator {
}

/// A progress point during a solve. Hand a callback to `solve_with`,
/// `first_solution_with` or `local_search_with` to observe them; `search_log`
/// builds one that prints progress lines. Costs are whole-solution
/// totals.
/// `first_solution_with`, `local_search_with` or `guided_local_search_with` to
/// observe them; `search_log` builds one that prints progress lines. Costs are
/// whole-solution totals.
///
/// The callback handed to `solve_with`, `local_search_with` or
/// `guided_local_search_with` is also a search monitor: return
/// `ControlFlow::Break(())` and the search stops at once, keeps the best
/// solution found, and reports `Done`. Construction cannot stop before every
/// node is placed, so `first_solution_with` takes a plain observer; a `Break`
/// on `FirstSolution` makes `solve_with` skip the improvement phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchEvent {
/// Construction placed every node; the first complete solution exists.
Expand All @@ -51,12 +59,17 @@ pub enum SearchEvent {
/// than anything before it. `cost` is the true cost, never the penalized
/// one the descent was reading.
GuidedBest { iter: usize, cost: Cost },
/// Local search converged; the solution is final.
/// Guided local search finished round `iter`; `cost` is the true cost of
/// the round's own solution. Fires every round, improving or not, so a
/// monitor can stop a search that has stalled.
GuidedRound { iter: usize, cost: Cost },
/// The search converged or the callback stopped it; the solution is final.
Done { cost: Cost },
}

/// An event callback that prints progress lines to stderr,
/// prefixed with elapsed time since the closure was created:
/// An event callback that prints progress lines to stderr, prefixed with
/// elapsed time since the closure was created. It skips `GuidedRound`, which
/// fires too often to read, and never stops the search:
///
/// ```text
/// #search 0.012s relocate improved, cost 5900
Expand All @@ -72,7 +85,7 @@ pub enum SearchEvent {
/// search_log(),
/// );
/// ```
pub fn search_log() -> impl FnMut(SearchEvent) {
pub fn search_log() -> impl FnMut(SearchEvent) -> ControlFlow<()> {
let started = Instant::now();
move |event| {
let t = started.elapsed().as_secs_f64();
Expand All @@ -86,8 +99,10 @@ pub fn search_log() -> impl FnMut(SearchEvent) {
SearchEvent::GuidedBest { iter, cost } => {
eprintln!("#search {t:7.3}s gls round {iter}, new best cost {cost}")
}
SearchEvent::GuidedRound { .. } => {}
SearchEvent::Done { cost } => eprintln!("#search {t:7.3}s done, cost {cost}"),
}
ControlFlow::Continue(())
}
}

Expand Down Expand Up @@ -132,23 +147,30 @@ impl Solution {
/// penalties included, is owned by the call. One model can therefore back any
/// number of solves running at once.
pub fn solve(m: &Model, construct: Construct, improve: Improve) -> Solution {
solve_with(m, construct, improve, |_| {})
solve_with(m, construct, improve, |_| ControlFlow::Continue(()))
}

/// `solve` with an observer for search progress. The callback runs on the
/// solver thread; keep it cheap or it becomes part of the measured time.
/// `solve` with a monitor: it sees every `SearchEvent` and can stop the search
/// early (see `SearchEvent`). The callback runs on the solver thread; keep it
/// cheap or it becomes part of the measured time.
pub fn solve_with(
m: &Model,
construct: Construct,
improve: Improve,
mut log: impl FnMut(SearchEvent),
mut log: impl FnMut(SearchEvent) -> ControlFlow<()>,
) -> Solution {
let mut sol = first_solution_with(m, construct, &mut log);
match improve {
Improve::HillClimb => local_search_with(m, &mut sol, &mut log),
Improve::Gls { iters } => guided_local_search_with(m, &mut sol, iters, &mut log),
let mut stopped = false;
let mut sol = first_solution_with(m, construct, |e| stopped = log(e).is_break());
if !stopped {
match improve {
Improve::HillClimb => local_search_with(m, &mut sol, &mut log),
Improve::Gls { iters } => guided_local_search_with(m, &mut sol, iters, &mut log),
}
}
let cost = eval_routes(m, &sol).expect("solver produced an infeasible solution");
if stopped {
let _ = log(SearchEvent::Done { cost });
}
Solution { routes: sol, cost }
}

Expand Down
42 changes: 26 additions & 16 deletions src/solver/descent.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::collections::VecDeque;
use std::ops::ControlFlow;

use super::operators::{try_or_opt, try_relocate, try_swap, try_two_opt, try_two_opt_star};
use super::{Operator, RouteEval, Scratch, SearchEvent};
Expand All @@ -18,12 +19,16 @@ pub(super) struct Move {
/// stays out of the per-node cascade — it fires once the fine operators reach
/// a fixpoint, as a partition-level kick, then the sweep resumes.
pub fn local_search(m: &Model, sol: &mut Routes) {
local_search_with(m, sol, |_| {})
local_search_with(m, sol, |_| ControlFlow::Continue(()))
}

/// `local_search` reporting an `Improvement` per accepted move and a final
/// `Done`.
pub fn local_search_with(m: &Model, sol: &mut Routes, log: impl FnMut(SearchEvent)) {
pub fn local_search_with(
m: &Model,
sol: &mut Routes,
log: impl FnMut(SearchEvent) -> ControlFlow<()>,
) {
descend(m, sol, eval_route, log)
}

Expand Down Expand Up @@ -72,7 +77,7 @@ pub(super) fn descend(
m: &Model,
sol: &mut Routes,
eval: impl RouteEval,
mut log: impl FnMut(SearchEvent),
mut log: impl FnMut(SearchEvent) -> ControlFlow<()>,
) {
let mut cost: Vec<Cost> = (0..sol.len())
.map(|v| eval(m, &sol[v], VehicleId(v as u32)).expect("infeasible start solution"))
Expand All @@ -84,11 +89,18 @@ pub(super) fn descend(
// node, so without this it rescans one route once per node in it.
let mut route_stale = vec![true; sol.len()];
let mut sx = Scratch::default();
let mut stop = |operator, cost: &[Cost]| {
log(SearchEvent::Improvement {
operator,
cost: cost.iter().sum(),
})
.is_break()
};

// Draining the queue is not a fixpoint: a move only re-wakes the two
// routes it touched, and a node elsewhere may now have an improving move
// into them. Re-sweep everything until a whole sweep finds nothing.
loop {
'search: loop {
// Future: Switch picking strategy.
// Now is going from 0...n. Deterministic order.
let mut queue: VecDeque<NodeId> = sol.iter().flatten().copied().collect();
Expand Down Expand Up @@ -133,10 +145,9 @@ pub(super) fn descend(
};

improved = true;
log(SearchEvent::Improvement {
operator,
cost: cost.iter().sum(),
});
if stop(operator, &cost) {
break 'search;
}
for t in [Some(r), other_route.filter(|&v| v != r)]
.into_iter()
.flatten()
Expand Down Expand Up @@ -166,19 +177,18 @@ pub(super) fn descend(
route_stale[r] = true;
route_stale[v] = true;
improved = true;
log(SearchEvent::Improvement {
operator: Operator::TwoOptStar,
cost: cost.iter().sum(),
});
if stop(Operator::TwoOptStar, &cost) {
break 'search;
}
}
}
}

if !improved {
log(SearchEvent::Done {
cost: cost.iter().sum(),
});
return;
break;
}
}
let _ = log(SearchEvent::Done {
cost: cost.iter().sum(),
});
}
Loading
Loading