diff --git a/core/src/build/revapi-differences.json b/core/src/build/revapi-differences.json index caa59d0ea5a..5b57cd00b1e 100644 --- a/core/src/build/revapi-differences.json +++ b/core/src/build/revapi-differences.json @@ -44,6 +44,17 @@ "old": "field ai.timefold.solver.core.config.heuristic.selector.move.generic.AbstractPillarMoveSelectorConfig>.subPillarSequenceComparatorClass", "new": "field ai.timefold.solver.core.config.heuristic.selector.move.generic.AbstractPillarMoveSelectorConfig>.subPillarSequenceComparatorClass", "justification": "Internal protected fields; safe." + }, + { + "ignore": true, + "code": "java.annotation.attributeValueChanged", + "old": "class ai.timefold.solver.core.config.solver.SolverConfig", + "new": "class ai.timefold.solver.core.config.solver.SolverConfig", + "annotationType": "jakarta.xml.bind.annotation.XmlType", + "attribute": "propOrder", + "oldValue": "{\"enablePreviewFeatureSet\", \"environmentMode\", \"daemon\", \"randomSeed\", \"moveThreadCount\", \"moveThreadBufferSize\", \"threadFactoryClass\", \"monitoringConfig\", \"solutionClass\", \"entityClassList\", \"scoreDirectorFactoryConfig\", \"terminationConfig\", \"nearbyDistanceMeterClass\", \"phaseConfigList\"}", + "newValue": "{\"enablePreviewFeatureSet\", \"environmentMode\", \"daemon\", \"randomSeed\", \"moveThreadCount\", \"moveThreadBufferSize\", \"threadFactoryClass\", \"monitoringConfig\", \"solutionClass\", \"entityClassList\", \"scoreDirectorFactoryConfig\", \"terminationConfig\", \"nearbyDistanceMeterClass\", \"phaseConfigList\", \"reuseBestSolution\"}", + "justification": "add support for reusing the best solution" } ] } diff --git a/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java b/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java index ed43fb1bceb..780d7a345f7 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java @@ -73,6 +73,7 @@ "terminationConfig", "nearbyDistanceMeterClass", "phaseConfigList", + "reuseBestSolution", }) public final class SolverConfig extends AbstractConfig { @@ -248,6 +249,8 @@ public final class SolverConfig extends AbstractConfig { @XmlElement(name = "monitoring") private MonitoringConfig monitoringConfig = null; + private Boolean reuseBestSolution = null; + // ************************************************************************ // Constructors and simple getters/setters // ************************************************************************ @@ -448,6 +451,14 @@ public void setMonitoringConfig(@Nullable MonitoringConfig monitoringConfig) { this.monitoringConfig = monitoringConfig; } + public @Nullable Boolean getReuseBestSolution() { + return reuseBestSolution; + } + + public void setReuseBestSolution(@Nullable Boolean reuseBestSolution) { + this.reuseBestSolution = reuseBestSolution; + } + // ************************************************************************ // With methods // ************************************************************************ @@ -600,6 +611,11 @@ public void setMonitoringConfig(@Nullable MonitoringConfig monitoringConfig) { return this; } + public @NonNull SolverConfig withReuseBestSolution(@NonNull Boolean reuseBestSolution) { + this.reuseBestSolution = reuseBestSolution; + return this; + } + // ************************************************************************ // Smart getters // ************************************************************************ @@ -677,6 +693,7 @@ public void offerRandomSeedFromSubSingleIndex(long subSingleIndex) { inheritedConfig.nearbyDistanceMeterClass); phaseConfigList = ConfigUtils.inheritMergeableListConfig(phaseConfigList, inheritedConfig.getPhaseConfigList()); monitoringConfig = ConfigUtils.inheritConfig(monitoringConfig, inheritedConfig.getMonitoringConfig()); + reuseBestSolution = ConfigUtils.inheritOverwritableProperty(reuseBestSolution, inheritedConfig.getReuseBestSolution()); return this; } diff --git a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java index 24431ceb65b..5d5cf2aa625 100644 --- a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java +++ b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java @@ -3,6 +3,7 @@ import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.Map; +import java.util.concurrent.locks.ReadWriteLock; import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Supplier; @@ -47,6 +48,7 @@ import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.solver.DefaultSolverFactory; +import ai.timefold.solver.core.impl.solver.recaller.ReusingBestSolutionUpdater; import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; import ai.timefold.solver.core.impl.solver.termination.SolverTermination; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel; @@ -210,6 +212,8 @@ Function, List propositionFunction, ScoreAnalysisFetchPolicy fetchPolicy); + ReusingBestSolutionUpdater buildReusingBestSolutionUpdater(ReadWriteLock readWriteLock); + enum Feature { MULTITHREADED_SOLVING("Multi-threaded solving", "remove moveThreadCount from solver configuration"), PARTITIONED_SEARCH("Partitioned search", "remove partitioned search phase from solver configuration"), @@ -219,7 +223,8 @@ enum Feature { "remove multistageMoveSelector and/or listMultistageMoveSelector from the solver configuration"), CONSTRAINT_PROFILING("Constraint profiling", "remove constraintStreamProfilingEnabled from the solver configuration"), SCORE_ANALYSIS("Score analysis", "do not use SolutionManager's analyze() method"), - RECOMMENDATIONS("Recommendations", "do not use SolutionManager's recommendAssignment() method"); + RECOMMENDATIONS("Recommendations", "do not use SolutionManager's recommendAssignment() method"), + REUSE_BEST_SOLUTION("Reuse best solution", "remove reuseBestSolution from solver configuration"); private final String name; private final String workaround; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java index 22ce614d31b..7f2e70e2890 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java @@ -43,6 +43,7 @@ public class HeuristicConfigPolicy { private final boolean unassignedValuesAllowed; private final Class> nearbyDistanceMeterClass; private final RandomGenerator random; + private final boolean reuseBestSolution; private final Map> entityMimicRecorderMap = new HashMap<>(); private final Map> subListMimicRecorderMap = new HashMap<>(); @@ -64,6 +65,7 @@ private HeuristicConfigPolicy(Builder builder) { this.unassignedValuesAllowed = builder.unassignedValuesAllowed; this.nearbyDistanceMeterClass = builder.nearbyDistanceMeterClass; this.random = builder.random; + this.reuseBestSolution = builder.reuseBestSolution; } public EnvironmentMode getEnvironmentMode() { @@ -122,6 +124,10 @@ public RandomGenerator getRandom() { return random; } + public boolean isReuseBestSolution() { + return reuseBestSolution; + } + // ************************************************************************ // Builder methods // ************************************************************************ @@ -138,7 +144,8 @@ public Builder cloneBuilder() { .withInitializingScoreTrend(initializingScoreTrend) .withSolutionDescriptor(solutionDescriptor) .withClassInstanceCache(classInstanceCache) - .withLogIndentation(logIndentation); + .withLogIndentation(logIndentation) + .withReuseBestSolution(reuseBestSolution); } public HeuristicConfigPolicy copyConfigPolicy() { @@ -276,6 +283,7 @@ public static class Builder { private Class> nearbyDistanceMeterClass; private RandomGenerator random; + private boolean reuseBestSolution = false; public Builder withPreviewFeatureSet(Set previewFeatureSet) { this.previewFeatureSet = previewFeatureSet; @@ -353,6 +361,11 @@ public Builder withUnassignedValuesAllowed(boolean unassignedValuesAl return this; } + public Builder withReuseBestSolution(boolean reuseBestSolution) { + this.reuseBestSolution = reuseBestSolution; + return this; + } + public HeuristicConfigPolicy build() { return new HeuristicConfigPolicy<>(this); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java index c3bfe0dbd95..a21bb1e527c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java @@ -121,7 +121,8 @@ protected void doStep(LocalSearchStepScope stepScope) { stepScope.getScoreDirector().executeMove(step); predictWorkingStepScore(stepScope, step); var solver = stepScope.getPhaseScope().getSolverScope().getSolver(); - solver.getBestSolutionRecaller().processWorkingSolutionDuringStep(stepScope); + solver.getBestSolutionRecaller().processWorkingSolutionDuringStep(stepScope, + stepScope.getPhaseScope().getAcceptedMoveList()); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java index fb1efeb6054..d757c3a6ccb 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java @@ -169,7 +169,8 @@ The move repository (%s) is neverEnding (%s), but the forager (%s) does not supp var moveThreadCount = configPolicy.getMoveThreadCount(); var environmentMode = configPolicy.getEnvironmentMode(); var decider = moveThreadCount == null - ? new LocalSearchDecider<>(configPolicy.getLogIndentation(), termination, moveRepository, acceptor, forager) + ? new LocalSearchDecider<>(configPolicy.getLogIndentation(), termination, moveRepository, acceptor, forager, + configPolicy.isReuseBestSolution()) : TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.MULTITHREADED_SOLVING) .buildLocalSearch(moveThreadCount, termination, moveRepository, acceptor, forager, environmentMode, configPolicy); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/LocalSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/LocalSearchDecider.java index 28940bfa482..b2646cf6bdc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/LocalSearchDecider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/LocalSearchDecider.java @@ -30,17 +30,20 @@ public class LocalSearchDecider { protected final MoveRepository moveRepository; protected final Acceptor acceptor; protected final LocalSearchForager forager; + protected final boolean reuseBestSolution; protected boolean assertMoveScoreFromScratch = false; protected boolean assertExpectedUndoMoveScore = false; public LocalSearchDecider(String logIndentation, PhaseTermination termination, - MoveRepository moveRepository, Acceptor acceptor, LocalSearchForager forager) { + MoveRepository moveRepository, Acceptor acceptor, LocalSearchForager forager, + boolean reuseBestSolution) { this.logIndentation = logIndentation; this.termination = termination; this.moveRepository = moveRepository; this.acceptor = acceptor; this.forager = forager; + this.reuseBestSolution = reuseBestSolution; } public Termination getTermination() { @@ -137,6 +140,9 @@ protected void pickMove(LocalSearchStepScope stepScope) { stepScope.setStepString(step.toString()); } stepScope.setScore(pickedMoveScope.getScore()); + if (reuseBestSolution) { + stepScope.getPhaseScope().addAcceptedMove(step); + } } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/scope/LocalSearchPhaseScope.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/scope/LocalSearchPhaseScope.java index 6fa927ede55..3db494c65dd 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/scope/LocalSearchPhaseScope.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/scope/LocalSearchPhaseScope.java @@ -1,8 +1,12 @@ package ai.timefold.solver.core.impl.localsearch.scope; +import java.util.ArrayList; +import java.util.List; + import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.preview.api.move.Move; /** * @param the solution type, the class with the {@link PlanningSolution} annotation @@ -10,6 +14,7 @@ public final class LocalSearchPhaseScope extends AbstractPhaseScope { private LocalSearchStepScope lastCompletedStepScope; + private final List> acceptedMoveList = new ArrayList<>(); public LocalSearchPhaseScope(SolverScope solverScope, int phaseIndex) { super(solverScope, phaseIndex); @@ -26,6 +31,14 @@ public void setLastCompletedStepScope(LocalSearchStepScope lastComple this.lastCompletedStepScope = lastCompletedStepScope; } + public List> getAcceptedMoveList() { + return acceptedMoveList; + } + + public void addAcceptedMove(Move move) { + acceptedMoveList.add(move); + } + // ************************************************************************ // Calculated methods // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirectorFactory.java index 7b392c7bceb..10b234bc8be 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirectorFactory.java @@ -9,7 +9,7 @@ import org.jspecify.annotations.NullMarked; @NullMarked -final class MoveTesterScoreDirectorFactory> +public final class MoveTesterScoreDirectorFactory> extends AbstractScoreDirectorFactory> { public MoveTesterScoreDirectorFactory(SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java index 5df65930e84..9e8c9ec5c3b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java @@ -5,6 +5,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.BooleanSupplier; import java.util.function.Consumer; @@ -66,33 +68,36 @@ public ConsumerSupport(ProblemId_ problemId, @Nullable Consumer { activeConsumption.release(); - tryConsumeWaitingIntermediateBestSolution(); + tryConsumeWaitingIntermediateBestSolution(lock); }, consumerExecutor); } } - private CompletableFuture scheduleIntermediateBestSolutionConsumption() { + private CompletableFuture scheduleIntermediateBestSolutionConsumption(Lock lock) { return CompletableFuture.runAsync(() -> { + // If reuse best solution is enabled, this will block if the best solution + // is currently being updated. + lock.lock(); var bestSolutionContainingProblemChanges = bestSolutionHolder.take(); if (bestSolutionContainingProblemChanges != null) { try { @@ -107,6 +112,7 @@ private CompletableFuture scheduleIntermediateBestSolutionConsumption() { bestSolutionContainingProblemChanges.completeProblemChangesExceptionally(throwable); } } + lock.unlock(); }, consumerExecutor); } @@ -175,7 +181,7 @@ void consumeFinalBestSolution(Solution_ solution) { // Called on the Solver thre // Situation: // The consumer is consuming the last but one best solution. The final best solution is waiting for the consumer. if (bestSolutionConsumer != null) { - scheduleIntermediateBestSolutionConsumption(); + scheduleIntermediateBestSolutionConsumption(new ReentrantLock()); } scheduleFinalBestSolutionConsumption(solution) .whenComplete((unused, throwable) -> releaseAll()); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java index 8d841424c32..f6f9c82b598 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java @@ -132,7 +132,8 @@ public Solver buildSolver(SolverConfigOverride configOverride) { solverScope.setProblemChangeDirector(new DefaultProblemChangeDirector<>(castScoreDirector)); var moveThreadCount = resolveMoveThreadCount(true); - var bestSolutionRecaller = BestSolutionRecallerFactory.create(). buildBestSolutionRecaller(environmentMode); + var bestSolutionRecaller = BestSolutionRecallerFactory.create(). buildBestSolutionRecaller(environmentMode, + solverConfig.getReuseBestSolution()); var randomFactory = buildRandomSupplier(environmentMode); var previewFeaturesEnabled = solverConfig.getEnablePreviewFeatureSet(); @@ -157,6 +158,7 @@ public Solver buildSolver(SolverConfigOverride configOverride) { .withInitializingScoreTrend(scoreDirectorFactory.getInitializingScoreTrend()) .withSolutionDescriptor(solutionDescriptor) .withClassInstanceCache(ClassInstanceCache.create()) + .withReuseBestSolution(Objects.requireNonNullElse(solverConfig.getReuseBestSolution(), false)) .build(); var basicPlumbingTermination = new BasicPlumbingTermination(isDaemon); var termination = buildTermination(basicPlumbingTermination, configPolicy, configOverride); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJob.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJob.java index 8c5fd857e62..7f3156e9340 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJob.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJob.java @@ -12,6 +12,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -33,6 +34,7 @@ import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListenerAdapter; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.score.director.ValueRangeManager; +import ai.timefold.solver.core.impl.solver.event.LockableSolverEventListener; import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.impl.solver.termination.SolverTermination; @@ -138,7 +140,7 @@ public Solution_ call() { solver.addPhaseLifecycleListener(new FirstInitializedSolutionPhaseLifecycleListener(currentConsumerSupport)); // add a phase lifecycle listener once when the solver starts its execution solver.addPhaseLifecycleListener(new StartSolverJobPhaseLifecycleListener(currentConsumerSupport)); - solver.addEventListener(this::onBestSolutionChangedEvent); + solver.addEventListener(new BestSolutionChangedEventListener()); final var finalBestSolution = solver.solve(problem); currentConsumerSupport.consumeFinalBestSolution(finalBestSolution); return finalBestSolution; @@ -161,20 +163,6 @@ public Solution_ call() { } } - private void onBestSolutionChangedEvent(BestSolutionChangedEvent bestSolutionChangedEvent) { - var currentConsumerSupport = consumerSupport.get(); - if (currentConsumerSupport == null) { // We set this, we should only set it once and before any event is emitted. - throw new IllegalStateException( - """ - Impossible state: Asked to consume a best solution changed event for problemId (%s), but the consumer is not set. - This means the solver job did not start properly or has already been terminated. This is likely a bug. - Please report this issue to Timefold with details on how to reproduce it.""" - .formatted(problemId)); - } - currentConsumerSupport.consumeIntermediateBestSolution(bestSolutionChangedEvent.getNewBestSolution(), - bestSolutionChangedEvent.getProducerId(), bestSolutionChangedEvent::isEveryProblemChangeProcessed); - } - private void solvingTerminated() { solverStatus.set(SolverStatus.NOT_SOLVING); solverManager.unregisterSolverJob(problemId); @@ -443,4 +431,23 @@ public void solvingStarted(SolverScope solverScope) { consumerSupport.consumeStartSolverJob(solverScope.getWorkingSolution()); } } + + private class BestSolutionChangedEventListener implements LockableSolverEventListener { + + @Override + public void bestSolutionChanged(BestSolutionChangedEvent bestSolutionChangedEvent, Lock lock) { + var currentConsumerSupport = consumerSupport.get(); + if (currentConsumerSupport == null) { // We set this, we should only set it once and before any event is emitted. + throw new IllegalStateException( + """ + Impossible state: Asked to consume a best solution changed event for problemId (%s), but the consumer is not set. + This means the solver job did not start properly or has already been terminated. This is likely a bug. + Please report this issue to Timefold with details on how to reproduce it.""" + .formatted(problemId)); + } + currentConsumerSupport.consumeIntermediateBestSolution(bestSolutionChangedEvent.getNewBestSolution(), + bestSolutionChangedEvent.getProducerId(), bestSolutionChangedEvent::isEveryProblemChangeProcessed, + lock); + } + } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/LockableSolverEventListener.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/LockableSolverEventListener.java new file mode 100644 index 00000000000..8635edfab5e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/LockableSolverEventListener.java @@ -0,0 +1,16 @@ +package ai.timefold.solver.core.impl.solver.event; + +import java.util.concurrent.locks.Lock; + +import ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent; +import ai.timefold.solver.core.api.solver.event.SolverEventListener; + +import org.jspecify.annotations.NonNull; + +public interface LockableSolverEventListener extends SolverEventListener { + default void bestSolutionChanged(@NonNull BestSolutionChangedEvent event) { + throw new UnsupportedOperationException(); + } + + void bestSolutionChanged(@NonNull BestSolutionChangedEvent event, @NonNull Lock lock); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java index 9cd9a413d11..d8af02f5fa1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.solver.event; +import java.util.concurrent.locks.Lock; + import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.event.EventProducerId; @@ -20,7 +22,7 @@ public SolverEventSupport(Solver solver) { } public void fireBestSolutionChanged(SolverScope solverScope, EventProducerId eventProducerId, - Solution_ newBestSolution) { + Solution_ newBestSolution, Lock readLock) { var it = getEventListeners().iterator(); var timeMillisSpent = solverScope.getBestSolutionTimeMillisSpent(); var bestScore = solverScope.getBestScore(); @@ -28,7 +30,12 @@ public void fireBestSolutionChanged(SolverScope solverScope, EventPro var event = new DefaultBestSolutionChangedEvent<>(solver, eventProducerId, timeMillisSpent, newBestSolution, bestScore); do { - it.next().bestSolutionChanged(event); + var eventListener = it.next(); + if (eventListener instanceof LockableSolverEventListener lockableSolverEventListener) { + lockableSolverEventListener.bestSolutionChanged(event, readLock); + } else { + eventListener.bestSolutionChanged(event); + } } while (it.hasNext()); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java index 91f645962b5..dbe4aee4926 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java @@ -1,15 +1,23 @@ package ai.timefold.solver.core.impl.solver.recaller; +import java.util.List; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListenerAdapter; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.impl.solver.event.SolverEventSupport; import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.preview.api.move.Move; + +import org.jspecify.annotations.Nullable; /** * Remembers the {@link PlanningSolution best solution} that a {@link Solver} encounters. @@ -21,8 +29,10 @@ public class BestSolutionRecaller extends PhaseLifecycleListenerAdapt protected boolean assertInitialScoreFromScratch = false; protected boolean assertShadowVariablesAreNotStale = false; protected boolean assertBestScoreIsUnmodified = false; - + protected boolean reuseBestSolution = false; protected SolverEventSupport solverEventSupport; + protected ReusingBestSolutionUpdater reusingBestSolutionUpdater; + private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); public void setAssertInitialScoreFromScratch(boolean assertInitialScoreFromScratch) { this.assertInitialScoreFromScratch = assertInitialScoreFromScratch; @@ -36,6 +46,19 @@ public void setAssertBestScoreIsUnmodified(boolean assertBestScoreIsUnmodified) this.assertBestScoreIsUnmodified = assertBestScoreIsUnmodified; } + public void setReuseBestSolution(boolean reuseBestSolution) { + this.reuseBestSolution = reuseBestSolution; + if (reuseBestSolution) { + reusingBestSolutionUpdater = TimefoldSolverEnterpriseService + .loadOrFail(TimefoldSolverEnterpriseService.Feature.REUSE_BEST_SOLUTION) + .buildReusingBestSolutionUpdater(readWriteLock); + } + } + + public boolean isReuseBestSolution() { + return reuseBestSolution; + } + public void setSolverEventSupport(SolverEventSupport solverEventSupport) { this.solverEventSupport = solverEventSupport; } @@ -79,7 +102,15 @@ public void processWorkingSolutionDuringConstructionHeuristicsStep(AbstractStepS updateBestSolutionWithoutFiring(solverScope, stepScope.getScore(), newBestSolution); } - public > void processWorkingSolutionDuringStep(AbstractStepScope stepScope) { + public void processWorkingSolutionDuringStep(AbstractStepScope stepScope) { + processWorkingSolutionDuringStep(stepScope, null); + } + + /** + * Return true if best solution was updated + */ + public > void processWorkingSolutionDuringStep(AbstractStepScope stepScope, + @Nullable List> acceptedMoveList) { var phaseScope = stepScope.getPhaseScope(); var score = stepScope. getScore(); var solverScope = phaseScope.getSolverScope(); @@ -87,10 +118,16 @@ public > void processWorkingSolutionDuringStep(Abst stepScope.setBestScoreImproved(bestScoreImproved); if (bestScoreImproved) { phaseScope.setBestSolutionStepIndex(stepScope.getStepIndex()); - var newBestSolution = stepScope.cloneWorkingSolution(); - var innerScore = buildInnerScore(solverScope.getSolutionDescriptor(). getScore(newBestSolution), - stepScope.getScoreDirector().getWorkingInitScore(), true); - updateBestSolutionAndFire(solverScope, phaseScope, innerScore, newBestSolution); + if (reuseBestSolution && acceptedMoveList != null) { + updateBestSolutionAndFire(solverScope, phaseScope, score, acceptedMoveList); + } else { + var newBestSolution = stepScope.cloneWorkingSolution(); + // Can this be removed? Seems to be the same as score? + var innerScore = + buildInnerScore(solverScope.getSolutionDescriptor(). getScore(newBestSolution), + stepScope.getScoreDirector().getWorkingInitScore(), true); + updateBestSolutionAndFire(solverScope, phaseScope, innerScore, newBestSolution); + } } else if (assertBestScoreIsUnmodified) { solverScope.assertScoreFromScratch(solverScope.getBestSolution()); } @@ -98,6 +135,11 @@ public > void processWorkingSolutionDuringStep(Abst public > void processWorkingSolutionDuringMove(InnerScore moveScore, AbstractStepScope stepScope) { + processWorkingSolutionDuringMove(moveScore, stepScope, null); + } + + public > void processWorkingSolutionDuringMove(InnerScore moveScore, + AbstractStepScope stepScope, @Nullable List> acceptedMoveList) { var phaseScope = stepScope.getPhaseScope(); var solverScope = phaseScope.getSolverScope(); var bestScoreImproved = moveScore.compareTo(solverScope.getBestScore()) > 0; @@ -105,16 +147,18 @@ public > void processWorkingSolutionDuringMove(Inne // stepScope.getBestScoreImproved() is initialized on false before the first call here if (bestScoreImproved) { stepScope.setBestScoreImproved(bestScoreImproved); - } - if (bestScoreImproved) { phaseScope.setBestSolutionStepIndex(stepScope.getStepIndex()); - var newBestSolution = solverScope.getScoreDirector().cloneWorkingSolution(); - // The solution for mixed models can generate a partially solved solution, - // as the complete solution will only be achieved when all variable types are assigned. - updateBestSolutionAndFire(solverScope, phaseScope, - buildInnerScore(moveScore.raw(), solverScope.getScoreDirector().getWorkingInitScore(), - solverScope.getScoreDirector().getSolutionDescriptor().hasBothBasicAndListVariables()), - newBestSolution); + // Can this be removed? Seems to be the same as moveScore? + var innerScore = buildInnerScore(moveScore.raw(), solverScope.getScoreDirector().getWorkingInitScore(), + solverScope.getScoreDirector().getSolutionDescriptor().hasBothBasicAndListVariables()); + if (reuseBestSolution && acceptedMoveList != null) { + updateBestSolutionAndFire(solverScope, phaseScope, innerScore, acceptedMoveList); + } else { + var newBestSolution = solverScope.getScoreDirector().cloneWorkingSolution(); + // The solution for mixed models can generate a partially solved solution, + // as the complete solution will only be achieved when all variable types are assigned. + updateBestSolutionAndFire(solverScope, phaseScope, innerScore, newBestSolution); + } } else if (assertBestScoreIsUnmodified) { solverScope.assertScoreFromScratch(solverScope.getBestSolution()); } @@ -122,14 +166,16 @@ public > void processWorkingSolutionDuringMove(Inne public void updateBestSolutionAndFire(SolverScope solverScope, AbstractPhaseScope phaseScope) { updateBestSolutionWithoutFiring(solverScope); - solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), solverScope.getBestSolution()); + solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), solverScope.getBestSolution(), + readWriteLock.readLock()); } public void updateBestSolutionAndFireIfInitialized(SolverScope solverScope, EventProducerId eventProducerId) { updateBestSolutionWithoutFiring(solverScope); if (solverScope.isBestSolutionInitialized()) { - solverEventSupport.fireBestSolutionChanged(solverScope, eventProducerId, solverScope.getBestSolution()); + solverEventSupport.fireBestSolutionChanged(solverScope, eventProducerId, solverScope.getBestSolution(), + readWriteLock.readLock()); } } @@ -137,7 +183,17 @@ private void updateBestSolutionAndFire(SolverScope solverScope, Abstr InnerScore bestScore, Solution_ bestSolution) { updateBestSolutionWithoutFiring(solverScope, bestScore, bestSolution); - solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), bestSolution); + solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), bestSolution, + readWriteLock.readLock()); + } + + private > void updateBestSolutionAndFire(SolverScope solverScope, + AbstractPhaseScope phaseScope, + InnerScore score, List> acceptedMoveList) { + reusingBestSolutionUpdater.updateReusingBestSolution(solverScope, score, acceptedMoveList); + updateBestSolutionWithoutFiring(solverScope, score, reusingBestSolutionUpdater.getBestSolution()); + solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), + reusingBestSolutionUpdater.getBestSolution(), readWriteLock.readLock()); } @SuppressWarnings({ "unchecked", "rawtypes" }) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecallerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecallerFactory.java index c56f244290b..8432b5ce67e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecallerFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecallerFactory.java @@ -8,13 +8,17 @@ public static BestSolutionRecallerFactory create() { return new BestSolutionRecallerFactory(); } - public BestSolutionRecaller buildBestSolutionRecaller(EnvironmentMode environmentMode) { + public BestSolutionRecaller buildBestSolutionRecaller(EnvironmentMode environmentMode, + Boolean reuseBestSolution) { BestSolutionRecaller bestSolutionRecaller = new BestSolutionRecaller<>(); if (environmentMode.isFullyAsserted()) { bestSolutionRecaller.setAssertInitialScoreFromScratch(true); bestSolutionRecaller.setAssertShadowVariablesAreNotStale(true); bestSolutionRecaller.setAssertBestScoreIsUnmodified(true); } + if (reuseBestSolution != null && reuseBestSolution) { + bestSolutionRecaller.setReuseBestSolution(true); + } return bestSolutionRecaller; } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/ReusingBestSolutionUpdater.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/ReusingBestSolutionUpdater.java new file mode 100644 index 00000000000..e4f3a42223a --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/ReusingBestSolutionUpdater.java @@ -0,0 +1,16 @@ +package ai.timefold.solver.core.impl.solver.recaller; + +import java.util.List; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.preview.api.move.Move; + +public interface ReusingBestSolutionUpdater { + > void updateReusingBestSolution(SolverScope solverScope, + InnerScore score, + List> movesSinceLastBestSolutionList); + + Solution_ getBestSolution(); +} diff --git a/core/src/main/resources/solver.xsd b/core/src/main/resources/solver.xsd index a5d10b20c96..9d8c73d9144 100644 --- a/core/src/main/resources/solver.xsd +++ b/core/src/main/resources/solver.xsd @@ -50,6 +50,8 @@ + + diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java index a94afe7c3b1..5b51ba614d2 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java @@ -75,7 +75,7 @@ void changeMoveBasedLocalSearch() { .buildAcceptor(heuristicConfigPolicy); var forager = LocalSearchForagerFactory . create(new LocalSearchForagerConfig().withAcceptedCountLimit(1)).buildForager(); - var localSearchDecider = new LocalSearchDecider<>("", termination, moveRepository, acceptor, forager); + var localSearchDecider = new LocalSearchDecider<>("", termination, moveRepository, acceptor, forager, false); var localSearchPhase = new DefaultLocalSearchPhase.Builder<>(0, "", termination, localSearchDecider).build(); // Generates a solution whose entities' values are all set to the second value. diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/ConsumerSupportTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/ConsumerSupportTest.java index d106234f7eb..94816ff15df 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/ConsumerSupportTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/ConsumerSupportTest.java @@ -13,6 +13,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import ai.timefold.solver.core.api.solver.Solver; @@ -139,6 +140,7 @@ private CompletableFuture addProblemChange(BestSolutionHolder true); + consumerSupport.consumeIntermediateBestSolution(bestSolution, EventProducerId.constructionHeuristic(0), () -> true, + new ReentrantLock()); } } diff --git a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java index 6a5f10d14fd..f3a065b7252 100644 --- a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java +++ b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java @@ -140,6 +140,8 @@ public static void updateSolverConfigWithRuntimeProperties(SolverConfig solverCo .ifPresent(solverConfig::setMoveThreadCount); maybeSolverRuntimeConfig.flatMap(SolverRuntimeConfig::randomSeed) .ifPresent(solverConfig::setRandomSeed); + maybeSolverRuntimeConfig.flatMap(SolverRuntimeConfig::reuseBestSolution) + .ifPresent(solverConfig::setReuseBestSolution); maybeSolverRuntimeConfig.flatMap(config -> config.termination().diminishedReturns()) .ifPresent(diminishedReturnsConfig -> setDiminishedReturns(solverConfig, diminishedReturnsConfig)); } diff --git a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/config/SolverRuntimeConfig.java b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/config/SolverRuntimeConfig.java index a7d9ced6bd5..fbe689f0a28 100644 --- a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/config/SolverRuntimeConfig.java +++ b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/config/SolverRuntimeConfig.java @@ -47,4 +47,16 @@ public interface SolverRuntimeConfig { * Configuration of the random seed. */ Optional randomSeed(); + + /** + * Enable reusing the best solution instance in events, improving performance. + * When enabled, the same best solution instance is reused and modified by the + * solver whenever the best solution changes. When enabled, ensure the best solution + * instance and entities are not saved outside the event handler. + * Defaults to "false". + *

+ * Note: this setting is only available in Timefold Solver + * Enterprise Edition. + */ + Optional reuseBestSolution(); } diff --git a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java index 1e425f04035..e83a7ce05d5 100644 --- a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java +++ b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java @@ -312,6 +312,9 @@ private void applyScoreDirectorFactoryProperties(IncludeAbstractClassesEntitySca if (solverProperties.getRandomSeed() != null) { solverConfig.setRandomSeed(solverProperties.getRandomSeed()); } + if (solverProperties.getReuseBestSolution() != null) { + solverConfig.setReuseBestSolution(solverProperties.getReuseBestSolution()); + } applyTerminationProperties(solverConfig, solverProperties.getTermination()); } diff --git a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperties.java b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperties.java index b45127fe40e..f18e0e1ff48 100644 --- a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperties.java +++ b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperties.java @@ -66,6 +66,8 @@ public class SolverProperties { */ private Long randomSeed; + private Boolean reuseBestSolution; + @NestedConfigurationProperty private TerminationProperties termination; @@ -145,6 +147,14 @@ public void setRandomSeed(Long randomSeed) { this.randomSeed = randomSeed; } + public Boolean getReuseBestSolution() { + return reuseBestSolution; + } + + public void setReuseBestSolution(Boolean reuseBestSolution) { + this.reuseBestSolution = reuseBestSolution; + } + public TerminationProperties getTermination() { return termination; } diff --git a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperty.java b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperty.java index d48ec18191b..92659635b9f 100644 --- a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperty.java +++ b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperty.java @@ -47,6 +47,8 @@ public enum SolverProperty { SolverProperties::setConstraintStreamAutomaticNodeSharing, value -> Boolean.valueOf(value.toString())), RANDOM_SEED("random-seed", SolverProperties::setRandomSeed, value -> Long.parseLong(value.toString())), + REUSE_BEST_SOLUTION("reuse-best-solution", SolverProperties::setReuseBestSolution, + value -> Boolean.parseBoolean(value.toString())), TERMINATION("termination", SolverProperties::setTermination, value -> { if (value instanceof TerminationProperties terminationProperties) { return terminationProperties; diff --git a/tools/benchmark/src/main/resources/benchmark.xsd b/tools/benchmark/src/main/resources/benchmark.xsd index 87ef47a9121..a043d1b4ba6 100644 --- a/tools/benchmark/src/main/resources/benchmark.xsd +++ b/tools/benchmark/src/main/resources/benchmark.xsd @@ -375,6 +375,9 @@ + + +