From 4faa904b191129129eecb21d598548666f31bce7 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 8 Nov 2026 21:38:08 +0100 Subject: [PATCH] fix(localsearch): resolve move_thread_count when the phase solves Lowering a local-search phase resolved move_thread_count through rayon::current_num_threads(), which starts the global Rayon pool when called outside one. Compiling any runner with an acceptor-forager local search and the default move_thread_count = "auto" therefore spawned a thread per core before anything was solved, and the policy followed the pool the runner was compiled in rather than the one the phase solves in. The lowered phase now keeps the configured MoveThreadCount and resolves it when it solves, on the pool it solves in. The cartesian gate case, which compiles a local-search runner without solving it, starts no threads again, and its allocation counts match v0.24.0 in every trial. Closes #221 --- crates/solverforge-config/WIREFRAME.md | 2 + .../src/phase/localsearch/mod.rs | 2 +- .../compiler/executor/runner/local_search.rs | 15 ++- .../compiler/executor/runner/lowering.rs | 7 +- .../tests/runner_compile_threads.rs | 42 +++++++ .../solverforge-solver/tests/support/mod.rs | 106 ++++++++++++++++++ 6 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 crates/solverforge-solver/tests/runner_compile_threads.rs create mode 100644 crates/solverforge-solver/tests/support/mod.rs 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()) +}