diff --git a/crates/solverforge-config/WIREFRAME.md b/crates/solverforge-config/WIREFRAME.md index 22712ee9..08a739cf 100644 --- a/crates/solverforge-config/WIREFRAME.md +++ b/crates/solverforge-config/WIREFRAME.md @@ -773,6 +773,8 @@ sizes partitioned search separately. | `Count(n >= 2)` | `min(n, rayon::current_num_threads(), 32)` evaluators: the main director plus that many minus one replicas; 32 is one full evaluation window | Every policy reaches the sequential trajectory; `auto` changes speed only. +`rayon::current_num_threads()` is read when the local-search phase starts, on +the pool it solves in; compiling the configuration starts no thread pool. `PartitionedSearchConfig::thread_count`: `auto` runs one thread per partition up to the available CPU cores and the partitioner's recommended partition count, `none` one thread, `{ count = n }` at most `n`. diff --git a/crates/solverforge-solver/src/phase/localsearch/mod.rs b/crates/solverforge-solver/src/phase/localsearch/mod.rs index 658da7b3..bea70132 100644 --- a/crates/solverforge-solver/src/phase/localsearch/mod.rs +++ b/crates/solverforge-solver/src/phase/localsearch/mod.rs @@ -24,6 +24,6 @@ pub(crate) use forager::{ AcceptedCountForager, BestScoreForager, FirstAcceptedForager, FirstBestScoreImprovingForager, FirstLastStepScoreImprovingForager, ForagerDecision, LocalSearchForager, }; -pub(crate) use parallel::{resolve_evaluation_policy, EvaluationPolicy}; +pub(crate) use parallel::resolve_evaluation_policy; pub(crate) use phase::solve_local_search_with_resources; pub(crate) use phase::LocalSearchPhase; diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs b/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs index a63363b9..88d9838f 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs @@ -6,7 +6,7 @@ use std::fmt::Debug; -use solverforge_config::ScoreTieBreak; +use solverforge_config::{MoveThreadCount, ScoreTieBreak}; use solverforge_core::domain::PlanningSolution; use solverforge_core::score::{ParseableScore, Score}; use solverforge_scoring::Director; @@ -15,8 +15,8 @@ use crate::builder::{AnyAcceptor, AnyForager, CompiledAcceptor, CompiledForager} use crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter; use crate::phase::localsearch::vnd::solve_vnd_with_resources; use crate::phase::localsearch::{ - solve_local_search_with_resources, AcceptedCountForager, BestScoreForager, - DiversifiedLateAcceptanceAcceptor, EvaluationPolicy, FirstLastStepScoreImprovingForager, + resolve_evaluation_policy, solve_local_search_with_resources, AcceptedCountForager, + BestScoreForager, DiversifiedLateAcceptanceAcceptor, FirstLastStepScoreImprovingForager, LateAcceptanceAcceptor, SimulatedAnnealingAcceptor, }; use crate::runtime::compiler::{ @@ -46,7 +46,9 @@ where forager: Option, score_tie_break: ScoreTieBreak, random_seed: Option, - evaluation_policy: EvaluationPolicy, + /// Resolved against the solve's thread pool when the phase runs, so + /// compiling a runner never starts a pool. + move_thread_count: MoveThreadCount, }, VariableNeighborhoodDescent { phase_termination: Option, @@ -80,7 +82,7 @@ where forager, score_tie_break, random_seed, - evaluation_policy, + move_thread_count, } => { let (mut acceptor, mut forager) = build_acceptor_and_forager::( *components, @@ -89,6 +91,7 @@ where *score_tie_break, *random_seed, ); + let evaluation_policy = resolve_evaluation_policy(move_thread_count); let mut source = selector.borrowed_source(); solver_scope.with_phase_termination(phase_termination.as_ref(), |solver_scope| { solve_local_search_with_resources( @@ -97,7 +100,7 @@ where &mut acceptor, &mut forager, None, - *evaluation_policy, + evaluation_policy, solver_scope, ); }); diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs b/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs index 77830ed2..49ae2ef9 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs @@ -11,7 +11,6 @@ use solverforge_core::domain::PlanningSolution; use solverforge_core::score::{ParseableScore, Score}; use crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter; -use crate::phase::localsearch::resolve_evaluation_policy; use crate::runtime::compiler::{ CompiledAcceptorForagerSelector, CompiledLocalSearch, CompiledRuntimeExtension, CompiledRuntimePhase, DefaultLocalSearchEligibility, DefaultRuntimeBindings, @@ -88,9 +87,7 @@ where forager: None, score_tie_break: solverforge_config::ScoreTieBreak::Random, random_seed: solver_config.resolved_random_seed(), - evaluation_policy: resolve_evaluation_policy( - &solver_config.move_thread_count, - ), + move_thread_count: solver_config.move_thread_count.clone(), }), Some(plan.candidate_trace_plan()), ) @@ -177,7 +174,7 @@ where forager, score_tie_break: config.score_tie_break, random_seed: solver_config.resolved_random_seed(), - evaluation_policy: resolve_evaluation_policy(&solver_config.move_thread_count), + move_thread_count: solver_config.move_thread_count.clone(), }, declaration, )) diff --git a/crates/solverforge-solver/tests/runner_compile_threads.rs b/crates/solverforge-solver/tests/runner_compile_threads.rs new file mode 100644 index 00000000..5ff991cf --- /dev/null +++ b/crates/solverforge-solver/tests/runner_compile_threads.rs @@ -0,0 +1,42 @@ +//! Compiling a runner whose local search uses `move_thread_count = "auto"` +//! starts no threads: the policy resolves against the pool the phase solves +//! in, so the global Rayon pool is not started at compile time. +//! +//! This is its own test binary because it reads the process's thread count, +//! which any other test starting a pool would change. It reads it from +//! procfs, so it runs on Linux only. +#![cfg(target_os = "linux")] + +use solverforge_config::{ + ChangeMoveConfig, LocalSearchConfig, MoveSelectorConfig, MoveThreadCount, PhaseConfig, + SolverConfig, +}; +use solverforge_solver::__bench::CompiledRuntimeRunner; + +mod support; + +fn thread_count() -> usize { + std::fs::read_dir("/proc/self/task") + .expect("procfs lists this process's threads") + .count() +} + +#[test] +fn compiling_an_auto_local_search_starts_no_threads() { + let config = SolverConfig { + random_seed: Some(7), + move_thread_count: MoveThreadCount::Auto, + phases: vec![PhaseConfig::LocalSearch(LocalSearchConfig { + move_selector: Some(MoveSelectorConfig::ChangeMoveSelector( + ChangeMoveConfig::default(), + )), + ..LocalSearchConfig::default() + })], + ..SolverConfig::default() + }; + let before = thread_count(); + let runner = + CompiledRuntimeRunner::compile(&config, support::context()).expect("the runner compiles"); + assert_eq!(thread_count(), before); + drop(runner); +} diff --git a/crates/solverforge-solver/tests/support/mod.rs b/crates/solverforge-solver/tests/support/mod.rs new file mode 100644 index 00000000..444b9059 --- /dev/null +++ b/crates/solverforge-solver/tests/support/mod.rs @@ -0,0 +1,106 @@ +//! A one-variable scalar model the solver's integration tests compile: +//! tasks assigned a worker from `0..4`. Each test binary uses part of it. +#![allow(dead_code)] + +use std::any::TypeId; + +use solverforge_core::domain::{ + EntityCollectionExtractor, EntityDescriptor, PlanningSolution, SolutionDescriptor, + VariableDescriptor, +}; +use solverforge_core::score::SoftScore; +use solverforge_solver::{ + CrossEntityDistanceMeter, RuntimeModel, ScalarVariableSlot, SearchContext, ValueSource, + VariableSlot, +}; + +#[derive(Clone, Debug)] +pub struct Task { + pub worker: Option, +} + +#[derive(Clone, Debug)] +pub struct Plan { + pub tasks: Vec, + pub score: Option, +} + +impl PlanningSolution for Plan { + type Score = SoftScore; + + fn score(&self) -> Option { + self.score + } + + fn set_score(&mut self, score: Option) { + self.score = score; + } +} + +#[derive(Clone, Copy, Debug)] +pub struct NoMeter; + +impl CrossEntityDistanceMeter for NoMeter { + fn distance(&self, _: &Plan, _: usize, _: usize, _: usize, _: usize) -> f64 { + 0.0 + } +} + +fn tasks(plan: &Plan) -> &Vec { + &plan.tasks +} + +fn tasks_mut(plan: &mut Plan) -> &mut Vec { + &mut plan.tasks +} + +fn task_count(plan: &Plan) -> usize { + plan.tasks.len() +} + +fn worker_get(plan: &Plan, entity: usize, _variable_index: usize) -> Option { + plan.tasks[entity].worker +} + +fn worker_set(plan: &mut Plan, entity: usize, _variable_index: usize, value: Option) { + plan.tasks[entity].worker = value; +} + +fn descriptor() -> SolutionDescriptor { + let entity = EntityDescriptor::new("Task", TypeId::of::(), "tasks") + .with_extractor(Box::new(EntityCollectionExtractor::new( + "Task", "tasks", tasks, tasks_mut, + ))) + .with_variable(VariableDescriptor::genuine("worker")); + SolutionDescriptor::new("Plan", TypeId::of::()).with_entity(entity) +} + +/// The search context binding the model's one scalar slot. +pub fn context() -> SearchContext { + let slot = VariableSlot::Scalar(ScalarVariableSlot::new( + 0, + 0, + "Task", + task_count, + "worker", + worker_get, + worker_set, + ValueSource::CountableRange { from: 0, to: 4 }, + false, + )); + SearchContext::new(descriptor(), RuntimeModel::new(vec![slot]), Some(7)) + .expect("the model binds to its descriptor") +} + +/// `count` tasks, none assigned. +pub fn unassigned(count: usize) -> Plan { + Plan { + tasks: (0..count).map(|_| Task { worker: None }).collect(), + score: None, + } +} + +/// A director over `plan` scoring the task count. +pub fn director(plan: Plan) -> solverforge_scoring::ScoreDirector { + solverforge_scoring::ScoreDirector::simple(plan, descriptor(), |plan, _| plan.tasks.len()) +}