From 36c8947302c9e10935ef10978be8318685f3929f Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Sun, 31 May 2026 21:59:00 -0400 Subject: [PATCH] perf: Support reusing the best solution instead of cloning in LS Currently the working solution is cloned when ever a new best solution is found. This is slow and create additional memory pressure on giant datasets, since each entity must be cloned. This adds a new SolverConfig property, `reuseBestSolution`, which will reuse the initial best solution clone in local search. When enabled, local search keep track of moves used in each step, which are then used to update the best solution instance. A read-write lock is used to allow reading the best solution safely outside the solver thread. This read-write lock is automatically used for SolverManager events. Since the same instance is reused, when this feature is enabled, users should ensure the solution and entities are not saved outside the event handler. --- core/src/build/revapi-differences.json | 11 +++ .../core/config/solver/SolverConfig.java | 17 ++++ .../TimefoldSolverEnterpriseService.java | 7 +- .../impl/heuristic/HeuristicConfigPolicy.java | 15 ++- .../localsearch/DefaultLocalSearchPhase.java | 3 +- .../DefaultLocalSearchPhaseFactory.java | 3 +- .../decider/LocalSearchDecider.java | 8 +- .../scope/LocalSearchPhaseScope.java | 13 +++ .../move/MoveTesterScoreDirectorFactory.java | 2 +- .../core/impl/solver/ConsumerSupport.java | 20 ++-- .../impl/solver/DefaultSolverFactory.java | 4 +- .../core/impl/solver/DefaultSolverJob.java | 37 +++++--- .../event/LockableSolverEventListener.java | 16 ++++ .../impl/solver/event/SolverEventSupport.java | 11 ++- .../solver/recaller/BestSolutionRecaller.java | 92 +++++++++++++++---- .../recaller/BestSolutionRecallerFactory.java | 6 +- .../recaller/ReusingBestSolutionUpdater.java | 16 ++++ core/src/main/resources/solver.xsd | 2 + .../impl/neighborhood/NeighborhoodsTest.java | 2 +- .../core/impl/solver/ConsumerSupportTest.java | 4 +- .../solver/quarkus/TimefoldRecorder.java | 2 + .../quarkus/config/SolverRuntimeConfig.java | 12 +++ .../TimefoldSolverAutoConfiguration.java | 3 + .../config/SolverProperties.java | 10 ++ .../autoconfigure/config/SolverProperty.java | 2 + .../src/main/resources/benchmark.xsd | 3 + 26 files changed, 269 insertions(+), 52 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/solver/event/LockableSolverEventListener.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/ReusingBestSolutionUpdater.java 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 @@ + + +