Repository navigation
feat(solver): parallel local-search move evaluation - #78
Draft
blackopsrepl wants to merge 13 commits into
Draft
blackopsrepl wants to merge 13 commits into
blackopsrepl wants to merge 13 commits into
Conversation
Parallel local-search evaluation needs independent score directors that start from the committed working solution. Director gains a defaulted fork_replica capability, and ScoreDirector implements it when it carries a constraint-set constructor (fn(&S) -> C): the replica clones the working solution, builds its own constraint set for that clone, and initializes it, so incremental state is never cloned or shared. A constructor that receives the solution serves static models and dynamic host-language models alike, since a binding can rebuild its constraint set from the runtime model the solution carries. Directors without a constructor keep returning None.
… entry The configured solve entry and the bridge's dynamic runner now take a constraint-set constructor, fn(&S) -> C, in place of a ready-made constraint set. The runtime builds the working solution's constraint set with it and records it on the score director, so every solve can fork evaluator replicas whose constraint sets are built the same way as the main one. Generated solves wrap the model's zero-argument constraints function; bindings rebuild their host constraint set from the solution they receive, which is what lets dynamic models take part in parallel evaluation. The bridge documents that its score-family scope is per thread, so binding constraint sets that build DynamicScore values must enter their family themselves when replicas score on worker threads.
…ting evaluate_candidate now composes two pieces: run_trial, which performs the pinned/doable check and the apply-score-undo-restore transaction against a director and nothing else, and account_trial, which records statistics and applies the hard- and score-improvement gates on the thread that owns the step scope. Parallel evaluation needs exactly this boundary: trials run on evaluator replicas while all accounting stays on the coordinator in candidate order. Evaluated-move timing is now passed in as a Duration so the accounting side no longer needs the trial's start instant. Sequential behaviour is unchanged.
…mption The step's candidate loop is now three explicit stages: record_generated_candidate records a pull (trace, generation telemetry, progress poll) and returns a WindowSlot, run_trial evaluates it, and consume_candidate feeds the outcome through accounting, the acceptor and the forager and releases what the step no longer needs. Parallel evaluation produces several slots before consuming them in order, so these stages must be callable separately. Decisions, checks and their order are unchanged. The evaluated-move duration recorded for a scored candidate now covers the trial itself rather than also including the acceptor call, which is the quantity a trial run on another thread can report.
…e traces Parallel evaluation evaluates a window of candidates before the coordinator consumes them in order, so a step can end with evaluated candidates that the sequential loop would never have reached. That work must be visible without distorting the logical counters that drive fixed-work limits. SolverStats and SolverTelemetry gain moves_speculative, recorded through PhaseScope::record_move_speculative, and the candidate trace gains the terminal DiscardedSpeculative disposition. Sequential evaluation never produces either, so existing telemetry and traces are unchanged.
…search Local search can now evaluate a step's candidates on several cores without changing the search. Each pass produces a window of candidates serially on the main director in canonical order, runs their trials on the main director plus persistent evaluator replicas inside one rayon::scope, and consumes the results in candidate order through the unchanged accounting, acceptor and forager. Before consuming each later slot the coordinator repeats the forager quit check and the interruption check the sequential loop makes, so with a window of one the loop is exactly the sequential one. Replicas are forked from the working solution and resynchronised whenever its new working_solution_generation counter shows a write outside the loop's own commits; the counter never resets, unlike solution_revision. The selected move is applied to every replica by borrow before the owned commit, and each replica must reach the committed step score. Windows hold 32 candidates regardless of the evaluator count and never exceed the remaining in-phase move or score-calculation budget, so fixed-work runs are reproducible for every count of two or more and logical counters match sequential evaluation; discarded work is reported as speculative. AGENTS.md records the replica copies as the same clone class as partition copies and the one-scope-per-window threading rule. The runtime still passes one evaluator; move_thread_count is wired next.
SolverConfig::move_thread_count was parsed but only documented, so there was no way to ask for parallel evaluation. The compiled runtime now resolves it into the evaluator count of every acceptor-forager local-search phase: an explicit count of two or more runs that many evaluators, capped by the threads of the Rayon pool the solve runs on; None, zero and one stay sequential. Auto also stays sequential for now, so default behaviour and memory use do not change until benchmarks decide its policy. A config test pins the documented TOML forms.
… runtime Pins the end-to-end contract through SolverManager and macro-generated solving: with move_thread_count 1, 2 and 4, a fixed-work solve (seeded construction plus 150 local-search steps) reaches the same assignment, score, step count, evaluated-move count and applied-move count. The plain-scalar default selectors keep no cross-step stream state, so sequential and parallel runs must match exactly. A second test pins that Auto and an explicit count of one stay sequential and report no speculative work. Speculation under parallel evaluation is asserted in the solver's unit tests instead, because an explicit count is capped by the runner's thread count.
README's performance section now says how to turn on parallel local-search evaluation for Rust and Python models, that results are reproducible at fixed work, and that the auto default stays sequential for now. The architecture map adds the window evaluation on replicas to the search-phase box, regenerated from its SVG source.
blackopsrepl
force-pushed
the
feat/parallel-move-evaluation
branch
from
October 22, 2026 18:35
0c1ec9f to
e7a296f
Compare
This was referenced Oct 22, 2026
Closed
The candidate loop allocated its window-slot and trial-result vectors afresh on every step, a new per-step heap allocation on the sequential path too. The evaluator pool now owns both buffers and lends them to each step, so steps reuse their capacity.
Replica forking used to add a constructor field to every ScoreDirector and copy the solution descriptor into each replica. The field alone slowed the list-swap selector gate by about 5% in cycles at identical instruction counts, and the descriptor copy was avoidable. ScoreDirector now takes how it holds its descriptor as a type parameter, defaulting to an owned SolutionDescriptor, so every existing director keeps its exact layout. The solve entry points build the main director with a ReplicaDescriptor: a borrow of the solve's descriptor plus the constraint-set constructor. Replicas copy that handle and share the descriptor; only the working solution is cloned. This also removes the descriptor copy the solve entry used to make for its main director.
The acceptor reads a candidate's tabu signature by reference, yet every evaluated candidate cloned the move's prebuilt signature first. Prebuilt signatures are now lent directly; only moves that build one on demand produce an owned value, for that decision alone.
Collaborator
Author
Benchmark: 1 vs 4 evaluatorsSetup. The series head is Employee scheduling (INRC-II)
Job shop (Taillard)
CVRP (X)
What this shows
Consequences for the open decisions
|
…ption A fixed 32-candidate window wastes most of each window when steps end early. On CVRP, where the configured forager stops after four accepted moves (about 23 evaluations per step), 40-45% of all trials were discarded and consumed evaluations fell 3-6% against one evaluator. A step's first window now matches the number of candidates the previous step consumed, at least one per evaluator, and each further window in the same step doubles up to 32. Candidates are still consumed in canonical order, so selectors without cross-step stream state keep the sequential trajectory at every evaluator count; ruin selectors and lazily activated providers stay reproducible for a given count.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #76
Goal
Evaluate local-search candidates on several cores within one solve, for static Rust models and dynamic host-language models alike, without changing the search algorithm:
Rayon is the only executor: one
rayon::scopeper evaluation window, on the solve thread's current pool.Non-goals
Design
Evaluator replicas
Director::fork_replica(&self) -> Option<Self>builds an independent replica: a clone of the working solution plus a constraint set built for that clone and initialized against it.ScoreDirectorcan fork when it carries a constraint-set constructor, set withwith_replica_constraints(fn(&S) -> C).Every solve entry point takes that constructor in place of a ready-made constraint set. It builds the main director's constraint set and every replica's with it, so main and replica sets are built the same way and every model can fork:
|_| define_constraints(), wrapping the model's zero-argument constraints function;solverforge_bridge::try_run_dynamic_solver_with_config_partstakes the samefn(&S) -> C. Bindings pass a function that rebuilds their host constraint set from the solution it receives; in solverforge-py that isPyDynamicConstraintSet::from_solution.When more than one evaluator is configured, a director that cannot fork is a wiring error and panics. There is no sequential fallback.
Replica initialization clones the working solution: once per replica at local-search phase start, and again whenever something other than the loop's own commits writes the working solution (construction before the phase, problem changes, the partitioned-search merge).
ProblemChange::applyonce per replica.This is the same class of clone as partitioned search's partition copies. It is cold — never per candidate, never per step.
Keeping replicas in sync
SolverScopegains a monotonicworking_solution_generation. It increments on every committed mutation and on every wholesale replacement, and it never resets; the existingsolution_revisiondoes reset on replacement. The evaluator pool records the generation it is synchronized to:&MoveCandidateRef(do_moveborrows). The replica's score must equal the committed step score; a mismatch panics with a divergence message, which the runtime reports through the existingFailedlifecycle. The pool then records the new generation.Dynamic models
Dynamic models run through the same compiled runtime and the same window loop. Parallel evaluation asks four things of a binding:
scoped_dynamic_score_familyis thread-local, and only the solve thread enters it. A constraint set that buildsDynamicScorevalues must therefore enter its declared family inside its ownConstraintSetmethods; otherwise worker-computed scores do not match the solve thread's.Host candidate providers run during production on the coordinator, exactly as today. A host exception raised inside a trial leaves
rayon::scopewith its original panic payload, so host tracebacks survive.The step
Each step alternates between producing, evaluating and consuming a window of candidates:
capacitycandidates serially on the main director, in canonical order. All existing control polls, trace pulls and generation telemetry run here exactly as today. Cartesian previews mutate and restore only the main director.MoveCandidateReffrom the cursor (shared borrows only). The main director and every replica pull slot indexes from oneAtomicUsizeand run the same trial kernel on their own director; the main director's share runs inline on the coordinator thread.i > 0, the coordinator re-applies the forager quit check and the interruption check that the sequential loop would have made at that point. Any slot not consumed is discarded as speculative work.capacityis1when no replicas exist. Otherwise it ismin(PARALLEL_EVALUATION_WINDOW, remaining in-phase move/score-calculation budget), withPARALLEL_EVALUATION_WINDOW = 32. The cursor never needs to beSync: the coordinator borrows the window's candidates, and workers receive only that shared slice.Determinism and accounting contract
move_thread_countresolving to1runs the current sequential loop: same candidates, same checks in the same order, same telemetry counts.moves_evaluated,score_calculations, step, move-count and score-calculation limits) count only consumed candidates. Fixed-work limits therefore trigger at the same point as in sequential evaluation.moves_generatedcounts every pull.SolverTelemetry::moves_speculativecounts candidates that were evaluated ahead of the decision point and then discarded.CandidateTraceDisposition::DiscardedSpeculative.Configuration
SolverConfig::move_thread_countis already part of the configuration surface. Today only partitioned search reads it.move_thread_countNoneAuto(default)Count(0),Count(1)Count(n ≥ 2)min(n, rayon::current_num_threads())evaluators: the main director plus that many minus one replicasTOML:
move_thread_count = { count = 4 }. Python configs pass the same key through:{"move_thread_count": {"count": 4}}.Rules for implementers
AGENTS.mdand load the session KV:nisaba --quiet docs/agents/drift-guard.nisaba scan drift-guard/. Mirror this checklist into your task ledger before the first code change.unsafe, nounsafe impl Send/Sync, noArc/Rc, no newdyn, nostd::thread::spawnorstd::thread::sleep, no.clone()of moves, candidates or scoring state. The only new clone is replica initialization inScoreDirector::fork_replica_impl.PhantomData<fn() -> T>.mod.rsfiles contain declarations and re-exports only. New files stay under 500 lines. Several files touched here are already over that size (run.rs,scope_core.rs,scope_progress.rs,stats/solver.rs,stats/candidate_trace.rs): add only the lines listed for them, and put new logic in new files.cargo check -p <crate> --all-targets --message-format short, the listed tests,cargo clippy -p <crate> --all-targets -- -D warnings, andcargo fmt --all -- --check.MoveCandidateRefturns out not to beSync, or a struct literal needs a field the plan does not mention.solverforge-bench, do not bump versions, and do not hand-editCHANGELOG.md. solverforge-py changes belong to their own pull request (see Downstream).Commit plan
C1 —
feat(scoring): fork evaluator replicas from ScoreDirectorcrates/solverforge-scoring/src/director/traits.rs. Add a defaulted method toDirector<S>, afterreset. Thewhere Self: Sizedclause keeps the trait object-safe for the existing&dyn Director<S>boundaries.crates/solverforge-scoring/src/director/score_director/incremental.rs:replica_constraints: Option<fn(&S) -> C>toScoreDirector, afterentity_counter, and initialize it toNoneinwith_descriptor. Every other constructor delegates towith_descriptor; confirm that.impl<S, C> ScoreDirector<S, C>block that defineswith_descriptor, add:Debugimpl lives inadapters.rs; show the new field there asreplica_constraints.is_some().crates/solverforge-scoring/src/director/score_director/adapters.rs. Inimpl<S, C> Director<S> for ScoreDirector<S, C>, addfn fork_replica(&self) -> Option<Self> { self.fork_replica_impl() }.crates/solverforge-scoring/src/director/tests/score_director.rs(add at most 90 lines). Add a helperfn unassigned_constraints(_: &TestSolution) -> (impl IncrementalConstraint<TestSolution, SoftScore>,) { (make_unassigned_constraint(),) }and three tests:fork_replica_requires_replica_constraints: a director built withoutwith_replica_constraintsreturnsNone.fork_replica_matches_score_and_stays_independent:ScoreDirector::new(solution.clone(), unassigned_constraints(&solution)).with_replica_constraints(unassigned_constraints)and callcalculate_score().get_score()equals the main director's.before_variable_changed, a direct write, thenafter_variable_changed.fork_of_uninitialized_director_initializes_the_replica: fork before anycalculate_score(), then assert the replica is initialized and its score is correct.crates/solverforge-scoring/WIREFRAME.md. DocumentDirector::fork_replicain the trait method list andwith_replica_constraintsin theScoreDirectorpublic methods (around line 507).cargo test -p solverforge-scoring fork_replica,cargo test -p solverforge-scoring, then clippy and fmt.C2 —
feat(solver): build constraint sets from a constructor at every solve entrycrates/solverforge-solver/src/run.rs:try_run_solver_with_config_and_search,try_run_solver_with_config_and_search_requestandtry_run_solver_with_candidate_trace_request, change the parameterconstraints: Ctoconstraints: fn(&S) -> Cand forward it along the chain.try_run_solver_with_candidate_trace_request, keeplog_scale(&solution);first and replace the director construction with:crates/solverforge-macros/src/planning_solution/runtime/solve.rs. Insolve_expr, replace the argument#constraints_fn(),with(|_: &Self| #constraints_fn()) as fn(&Self) -> _,.crates/solverforge-bridge/src/runner.rs. Intry_run_dynamic_solver_with_config_parts, changeconstraints: Ctoconstraints: fn(&S) -> Cand forward it. Add to its doc comment: "constraintsbuilds a constraint set for a given solution. The runtime calls it for the working solution and for every evaluator replica, somove_thread_countevaluates dynamic models in parallel."crates/solverforge-bridge/src/score.rs. Extend the doc comment ofscoped_dynamic_score_family: "The family is per thread. The solve thread enters it for the whole solve, but parallel evaluation scores evaluator replicas on Rayon worker threads, so a constraint set that buildsDynamicScorevalues must also enter its declared family inside its ownConstraintSetmethods."crates/solverforge-solver/src/run_tests.rs(around line 168): addfn test_constraints(_: &TestSolution) -> ScoreFromSolutionConstraints { ScoreFromSolutionConstraints }and passtest_constraints.crates/solverforge-solver/src/manager/mod_tests_integration/lifecycle_solutions.rs(around line 565): addfn zero_variable_constraints(_: &ZeroVariableLifecycleSolution) {}and passzero_variable_constraintsin place of().crates/solverforge-macros/src/planning_solution_tests.rs. Extend the existing expansion test that checks fortry_run_solver_with_config_and_searchso it also asserts the constructor cast. Print the expansion once to getquote!'s exact spacing (for examplefn (& Self) -> _).crates/*/WIREFRAME.mdfortry_run_solver_with_config_and_searchandtry_run_dynamic_solver_with_config_parts, and update each signature to the constructor parameter.crates/solverforge-macros/WIREFRAME.md: thesolve_internalbullet (near line 257) says the constraints function is passed as the constraint-set constructor.crates/solverforge-bridge/WIREFRAME.md: the runner's constructor parameter, and the per-thread rule forscoped_dynamic_score_family.cargo check --workspace --all-targets --message-format short,cargo test -p solverforge-macros,cargo test -p solverforge --tests(the facade fixtures exercise the generated code),cargo test -p solverforge-bridge,cargo test -p solverforge-solver.C3 —
feat(solver): track working-solution generationsLanded inside C7: the counter has no reader until the evaluator pool exists, and the lint gate rejects dead code.
crates/solverforge-solver/src/scope/solver/scope_core.rs:working_solution_generation: u64toSolverScope, aftersolution_revision. Initialize it to0in every constructor; the compiler lists the struct literals.self.working_solution_generation += 1;as the first statement ofreplace_working_solution_and_reinitialize.pub(crate) fn working_solution_generation(&self) -> u64.crates/solverforge-solver/src/scope/solver/scope_progress.rs. Incommitted_mutation, addself.working_solution_generation += 1;directly afterself.advance_solution_revision();.crates/solverforge-solver/src/scope/tests.rs(add at most 40 lines). Addworking_solution_generation_is_monotonic_across_commits_and_replacement:mutate(|_| ())twice and assert the generation advanced by 2.replace_working_solution_and_reinitialize(...)and assert it advanced by 1 more.solution_revisionwent back to 1.cargo test -p solverforge-solver scope::.C4 —
refactor(solver): split the local-search trial kernel from its accountingPure refactor; every existing test must pass unchanged.
crates/solverforge-solver/src/phase/localsearch/evaluation.rs:run_trial. Its body is the director-only part of today'sevaluate_candidate, in the same order: the pinned/doable check returningNotDoable,snapshot_score_state,do_move_with_scratch,calculate_score,undo_move_with_scratch,restore_score_state, thenScored(score). It must not touch any statistics.pub(crate) fn account_trial(mov, step_scope, reference_score, selector_index, elapsed: Duration, outcome: TrialOutcome<S::Score>) -> CandidateEvaluation<S::Score>. Its body is all of today's bookkeeping, in today's order:NotDoable: evaluated-move record, unscored label, not-doable counters.Scored:record_score_calculation, the hard-delta counters, then the hard-improvement and score-improvement rejections.evaluate_candidatekeeps its signature and becomesaccount_trial(mov, step_scope, reference_score, selector_index, evaluation_started.elapsed(), run_trial(mov, step_scope.score_director_mut(), journal_pool)). Bind the outcome first soelapsed()is taken after the trial.record_evaluated_move(step_scope, selector_index, elapsed: Duration)to take aDuration. Update its callers:phase/localsearch/phase/candidates.rs(around line 220) andphase/localsearch/vnd/phase.rs(around line 341) both passevaluation_started.elapsed().cargo test -p solverforge-solver localsearch, then the fullcargo test -p solverforge-solver.C5 —
refactor(solver): extract local-search candidate generation and consumptionPure refactor of
crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs; behaviour is byte-identical.crates/solverforge-solver/src/phase/localsearch/phase/decision.rsand declaremod decision;next tomod candidates;inphase/localsearch/phase.rs. It contains:pub(super) fn record_generated_candidate(cursor: &C, candidate_id, generation_started: Instant, step_scope, state: &mut CandidateLoopState) -> WindowSlot. This is today's block from the selector-index lookup through the progress poll (lines 102–137): selector index, move and selector labels,record_candidate_pull,generated_moves += 1, generated-move telemetry with the generation elapsed time, and the progress poll.pub(super) fn consume_candidate(cursor: &mut C, step_scope, slot: &WindowSlot, outcome: TrialOutcome<S::Score>, elapsed: Duration, last_step_score, acceptor, forager, requires_move_signatures, state: &mut CandidateLoopState). This is today's block fromstate.evaluated_moves += 1to the end of the loop body (lines 156–303), withevaluate_candidatereplaced byaccount_trial(&mov, …, elapsed, outcome)and the post-acceptorrecord_evaluated_movegivenelapsed. Re-borrowmovfromcursor.candidate(slot.id), and keep its last use before anycursor.release_candidatecall, as today.candidates.rs, the loop body becomes:record_generated_candidate;run_trialtimed withInstant::now();consume_candidate.cargo test -p solverforge-solver localsearch; every existing foraging, streaming and selector test passes with no expectation changed.C6 —
feat(solver): report speculative evaluation in telemetry and candidate tracescrates/solverforge-solver/src/stats/solver.rs. Add amoves_speculative: u64field toSolverStats, declared likemoves_evaluated, withpub fn record_move_speculative(&mut self), and copy it in the telemetry snapshot (around line 279).crates/solverforge-solver/src/stats/telemetry.rs. Addpub moves_speculative: u64toSolverTelemetry, documented as "Candidates evaluated ahead of the sequential decision point by parallel evaluation and then discarded. Zero for sequential evaluation." Initialize it to0innew_const.crates/solverforge-solver/src/scope/solver/scope_progress.rs. Addpub(crate) fn record_move_speculative(&mut self)next torecord_score_calculation.crates/solverforge-solver/src/scope/phase.rsforwards it throughPhaseScope, mirroringrecord_score_calculationaround line 486.crates/solverforge-solver/src/stats/candidate_trace.rs. Add aDiscardedSpeculativevariant documented as "Parallel evaluation pulled this candidate ahead of the sequential decision point; the step ended before the candidate was consumed." Give it code10incode()and list it inis_terminal().manager/mod_tests_integration/resume_support.rsandmanager/mod_tests_integration/runtime_helpers.rs.crates/solverforge-solver/src/stats/tests.rs. Addspeculative_moves_reach_the_telemetry_snapshot.crates/solverforge-solver/WIREFRAME.md. Add theSolverTelemetryfield and theCandidateTraceDispositionvariant.cargo test -p solverforge-solver stats::,cargo check --workspace --all-targets(the console crate must still build).C7 —
feat(solver): parallel evaluation windows for acceptor-forager local searchNew module
crates/solverforge-solver/src/phase/localsearch/parallel/:mod.rs— declarations and re-exports only:budget.rs:inphase_move_count_limit - stats().moves_evaluatedandinphase_score_calc_count_limit - stats().score_calculations, both withsaturating_sub, andNonewhen neither limit is set. Both limit fields are alreadypub.pool.rs:S: PlanningSolution, D: Director<S>, M: Move<S>. If the compiler reports an unused type parameter, add_phantom: PhantomData<fn() -> (S, M)>. Methods:new(requested: usize) -> Self: storesrequested.max(1)— the number of evaluators, main director included — with no replicas.is_parallel(&self) -> bool: true when replicas exist.window_capacity(&self, remaining: Option<u64>) -> usize:1when not parallel; otherwisePARALLEL_EVALUATION_WINDOW.min(remaining as usize).max(1), with a missing budget meaning unbounded.sync(&mut self, solver_scope: &SolverScope<'_, S, D, BestCb>):requested <= 1orsynced_generation == Some(solver_scope.working_solution_generation()).requested - 1of them withsolver_scope.score_director().fork_replica().expect("parallel evaluation requires a director built with a constraint-set constructor"), then setsynced_generation. The main director is the remaining evaluator.advance(&mut self, selected: &MoveCandidateRef<'_, S, M>, expected: S::Score): when parallel,self.replicas.par_iter_mut().for_each(|replica| { let _undo = selected.do_move(&mut replica.director); let score = replica.director.calculate_score(); assert_eq!(score, expected, "evaluator replica diverged from the committed step"); }).mark_synced(&mut self, generation: u64): when parallel, setsynced_generation = Some(generation).pub(super) fn parts_mut(&mut self) -> (&mut Vec<(usize, TrialOutcome<S::Score>, Duration)>, &mut [EvaluatorReplica<S, D, M>]): the main director's output buffer and the replicas, borrowed disjointly.#[cfg(test)] pub(super) fn replica_count(&self) -> usize.window.rs. The coordinator borrows the window's candidates from the cursor and passes the slice in, so this function needs no cursor type and the cursor never has to beSync. The main director takes its share inline on the coordinator thread; the replicas take theirs in spawned tasks.A worker panic propagates out of
rayon::scopewith its original payload, exactly like a sequential trial panic.Modified files:
phase/localsearch/mod.rs. Addmod parallel;.phase/localsearch/phase.rs:solve_local_search_with_resourcesgains the parameterevaluator_count: usizeafterstep_limit. It createslet mut evaluators = EvaluatorPool::<S, D, M>::new(evaluator_count);before the loop and passes&mut evaluatorstoexecute_step.LocalSearchPhase::solvepasses1.phase/localsearch/phase/step.rs.execute_steptakesevaluators: &mut EvaluatorPool<S, D, M>:StepScope::new:evaluators.sync(step_scope.phase_scope().solver_scope());.evaluatorstoevaluate_candidates.step_scope.apply_committed_change(...):evaluators.advance(&selected_move, selected_score);.apply_committed_changereturns:evaluators.mark_synced(step_scope.phase_scope().solver_scope().working_solution_generation());.phase/localsearch/phase/decision.rs. Add the discard helpers:interrupt_window_at(cursor, step_scope, window: &mut [WindowSlot], index, evaluated: bool):index: record theInterruptedBeforeEvaluationdisposition, exactly as the sequential loop does, and do not release it.DiscardedSpeculativeand release it.evaluatedis true, callrecord_move_speculative()for slotindexand every later slot.discard_window_from(cursor, step_scope, window, from, evaluated: bool): recordDiscardedSpeculativefor slotsfrom.., release them, and count them as speculative whenevaluatedis true.phase/localsearch/phase/candidates.rs.evaluate_candidatestakesevaluators: &mut EvaluatorPool<S, D, M>, and its loop becomes:With
capacity == 1this performs the same checks, pulls, records and decisions in the same order as today. That property is what keeps every existing local-search test valid, so do not reorder anything to make the code shorter.runtime/compiler/executor/runner/local_search.rs. Pass1for now; C8 wires the configuration.parallel/tests.rs. Build a director with real incremental scoring overcrate::test_utils::NQueensSolution:solverforge_scoring::IncrementalUniConstraintoverqueens, built withsolverforge_scoring::stream::collection_extract::{source, ChangeSource}andChangeSource::Descriptor(0). Follow the pattern insolverforge-scoring/src/director/tests/score_director.rs, and use a non-constant weight such as(row * 3 + column) % 5. Wrap it infn constraints(_: &NQueensSolution) -> (impl IncrementalConstraint<NQueensSolution, SoftScore>,).ScoreDirector::with_descriptor(solution.clone(), constraints(&solution), create_nqueens_descriptor(), |s, _| s.queens.len()).with_replica_constraints(constraints).SolverScope::new(director).with_seed(7)followed bystart_solving().SelectorCursorSource::new(ChangeMoveSelector::simple(get_queen_row, set_queen_row, 0, 0, "row", values)).Tests:
pool_with_one_evaluator_never_forks: build the director withScoreDirector::simple(no constructor), create the pool withrequested = 1, thensync; assert!is_parallel()andwindow_capacity(None) == 1.pool_requires_a_forkable_director: the same director withrequested = 2;#[should_panic(expected = "constraint-set constructor")]onsync.pool_rebuilds_replicas_when_the_generation_changes: withrequested = 4, sync and assertreplica_count() == 3; commit a change throughsolver_scope.mutate(...), sync again, then assert the replica scores equal the main director's score.sequential_and_parallel_reach_identical_trajectories: runHillClimbingAcceptorwithAcceptedCountForager::new(4, false)and step limit 25 for evaluator counts 1, 2 and 4. Assert equal queen rows,moves_evaluated,moves_appliedand step counts.speculative_evaluations_are_reported_and_not_counted: runAcceptedCountForager::new(1, false)with evaluator counts 1 and 2. Assert equalmoves_evaluated, a nonzeromoves_speculativefor count 2, and zero for count 1.in_phase_move_limit_reserves_the_window: setsolver_scope.inphase_move_count_limit = Some(5)and runBestScoreForagerwith evaluator count 2. Assertmoves_evaluated == 5andmoves_speculative == 0.replicas_follow_solution_replacement: run a phase, callreplace_working_solution_and_reinitializewith a different solution, then run a second phase with evaluator count 2. The run must complete without a divergence panic and match the same sequence run with evaluator count 1.AGENTS.md:rayon::scopeper evaluation window on the solve thread's current pool; do not add dedicated pools or blocking waits inside evaluation workers."crates/solverforge-solver/WIREFRAME.md:phase/localsearch/parallel/{mod,budget,pool,window,tests}.rsandphase/localsearch/phase/decision.rs.Verify:
cargo test -p solverforge-solver localsearch, the fullcargo test -p solverforge-solver, clippy and fmt.C8 —
feat(solver): drive acceptor-forager evaluation from move_thread_countphase/localsearch/parallel/config.rs(new; declare it inparallel/mod.rsand re-export it):runtime/compiler/executor/runner/local_search.rs. Addevaluator_count: usizetoRuntimeLocalSearch::AcceptorForager, destructure it insolve, and pass it tosolve_local_search_with_resources.runtime/compiler/executor/runner/lowering.rs. Setevaluator_count: resolve_evaluator_count(&solver_config.move_thread_count)at bothRuntimeLocalSearch::AcceptorForagerconstruction sites: the default-runtime branch near line 82 andlower_local_searchnear line 163.parallel/tests.rs. Addevaluator_count_resolution:None,Auto,Count(0)andCount(1)resolve to 1, andCount(2)resolves tomin(2, rayon::current_num_threads()).crates/solverforge-config/WIREFRAME.md. UnderMoveThreadCount(around line 713) and themove_thread_countrow (around line 63), document the consumers and the configuration table above.cargo test -p solverforge-solver runtime::,cargo test -p solverforge-solver,cargo test -p solverforge-config.C9 —
test(solverforge): parallel local-search parity through the generated runtimecrates/solverforge/tests/parallel_local_search/domain/, modelled onscalar_runtime_publication/domain/:mod.rs:solverforge::planning_model!withroot = "crates/solverforge/tests/parallel_local_search/domain"and modulesplan,resource,task.resource.rs: the same shape as the existingResource.task.rs:#[planning_id] pub id: String,pub weight: i64, and#[planning_variable(value_range_provider = "resources", allows_unassigned = true)] pub resource_idx: Option<usize>.plan.rs:#[planning_solution(constraints = "define_constraints", config = "parallel_config")]withresources,tasks,score: Option<HardSoftScore>, and a plain fieldpub move_threads: u32.(task.weight * (resource_idx as i64 + 3)) % 17for each assigned task.parallel_config(solution, _base)parses the TOML below withSolverConfig::from_toml_str, setsconfig.move_thread_counttoMoveThreadCount::Autowhenmove_threads == 0and toMoveThreadCount::Count(n)otherwise, and returns the config.crates/solverforge/tests/parallel_local_search.rs. Use 6 resources and 40 tasks with deterministic weights (for example(i * 7 + 3) % 11). Solve throughSolverManageras inscalar_runtime_publication.rs.fixed_work_trajectory_is_identical_across_thread_counts: formove_threads1, 2 and 4, collect the completed solution andmetadata.telemetry. Assert identicalresource_idxvectors, scores,step_count,moves_evaluatedandmoves_applied. The default plain-scalar selectors keep no stream state across steps, so all three runs must match.sequential_resolution_reports_no_speculation: withmove_threads0 (Auto) and 1, assertmoves_speculative == 0.moves_speculative > 0here: on a single-core runnerCount(n)clamps to 1. The unit tests in C7 cover speculation independently of the core count.cargo test -p solverforge --test parallel_local_search,cargo test -p solverforge --tests.C10 —
docs: describe parallel local-search evaluationREADME.md. In the existing configuration or performance guidance, add two or three lines:move_thread_count = { count = 4 }evaluates local-search candidates on up to four cores, for Rust models and for Python models through solverforge-py; results are reproducible at fixed work. If the README has no such section, skip this file.docs/architecture.html. Check whetherassets/solverforge-architecture.svgdepicts the local-search step. If it does, add the evaluator replicas to the SVG and regenerate the page withnode scripts/solverforge-architecture.js. Do not edit the HTML by hand. If the step is not depicted, make no change.Verification gates
Run each gate once per landed series and record its verdict on this PR.
make build,make examples,make test,make lint,make pre-release. All must pass with zero ignored tests.BASELINE=<merge-base of this branch with main> BASELINE_CURSOR_API=1 make bench-zero-regression.report.json.bdcdb644, baseline1f224d06). All 12 cases match semantically and allocation counts match exactly.list_swapmeasured +7% cycles at identical instruction and branch counts, reproduced across runs. Topdown counters put the difference in front-end bandwidth: the case's loop is inlined into the gate binary'smain, whose start moved from offset 48 to 32 within a 64-byte block because unrelated code changed size. An unused field added toScoreDirectoron the baseline alone reproduced it (+5%).RUSTFLAGS="-C llvm-args=-align-all-functions=6 -C llvm-args=-align-loops=64"), every case's wall-time and cycle median is within ±2% of baseline (list_swap+0.10% wall / +0.33% cycles; worstconstruction_full+1.06% / +1.95%;k_opt−3.8% wall). The only failing criterion is cache-misses onlist_swapandnearby_swap(+11.5%, +10.2% on small absolute counts).move_thread_count1 againstCount(4)on the scalar, list and CVRP suites using the existing benchmark infrastructure.Decisions needed before merge
Auto. This PR resolvesAutoto sequential for local search, so default behaviour and memory use do not change. Turning it on (for examplemin(available cores − 1, 4)) is a one-line change inresolve_evaluator_count, made once the benchmark verdict is in.PARALLEL_EVALUATION_WINDOW = 32is an internal constant. It sets both speculative waste on early-quitting foragers and per-window overhead. Make it configurable only if the benchmark shows a shape-dependent optimum.generation_timeandevaluation_timefrom the benchmark adapters. Serial generation caps the speedup wherever candidate construction dominates; grouped-scalar compound moves are the likely case.Downstream
solverforge-py
The Python-binding work lives in SolverForge/solverforge-py#1 and lands after the release that ships this series.
Portfolio
The cooperative portfolio suggested in #76 can build on
Director::fork_replicaandEvaluatorPoolas a separate scheduler, in its own issue.