perf: Support reusing the best solution instead of cloning in LS - #2332
perf: Support reusing the best solution instead of cloning in LS#2332Christopher-Chianelli wants to merge 1 commit into
Conversation
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.
|
zepfred
left a comment
There was a problem hiding this comment.
Reusing the best solution is a great idea! I have some questions and suggestions about not changing the existing contract to achieve the goal of this PR.
|
|
||
| public void fireBestSolutionChanged(SolverScope<Solution_> solverScope, EventProducerId eventProducerId, | ||
| Solution_ newBestSolution) { | ||
| Solution_ newBestSolution, Lock readLock) { |
There was a problem hiding this comment.
Why are we including the lock as a parameter? Considering the principle of separation of concerns, wouldn't it be better for the lock to be part of the LockableSolverEventListener instead?
There was a problem hiding this comment.
This fires events for all listeners. SolverManager fire off its events in a seperate thread, which makes a lock neccessary for it but not other event listeners. This is not a part of the API; it just propagate events.
| new DefaultBestSolutionChangedEvent<>(solver, eventProducerId, timeMillisSpent, newBestSolution, bestScore); | ||
| do { | ||
| it.next().bestSolutionChanged(event); | ||
| var eventListener = it.next(); |
There was a problem hiding this comment.
If the listener contract remains unchanged, there will be no need to update the block.
There was a problem hiding this comment.
LockableSolverEventListener is a SolverEventListener; SolverEventListener is not a LockableSolverEventListener. The block changed so a lock can be passed to LockableSolverEventListener without adapting all SolverEventListener.
| "terminationConfig", | ||
| "nearbyDistanceMeterClass", | ||
| "phaseConfigList", | ||
| "reuseBestSolution", |
There was a problem hiding this comment.
I'm not sure if we need a new property. Do we always want to use the new related logic? Are there scenarios where enabling this feature by default may not be recommended?
There was a problem hiding this comment.
+1. Already discussed on Slack with Chris; no external configuration, we do the right thing.
There was a problem hiding this comment.
The primary reason for the switch is that this reuses the existing solution; users might rely on getting new best solution instances if they save them outside the event handler (ex: store them in a MutableReference).
|
|
||
| import org.jspecify.annotations.NonNull; | ||
|
|
||
| public interface LockableSolverEventListener<Solution_> extends SolverEventListener<Solution_> { |
There was a problem hiding this comment.
I wonder if we really need a new contract. Can we provide a new event listener implementation that incorporates the required logic with locking without changing the contract?
| public void setReuseBestSolution(boolean reuseBestSolution) { | ||
| this.reuseBestSolution = reuseBestSolution; | ||
| if (reuseBestSolution) { | ||
| reusingBestSolutionUpdater = TimefoldSolverEnterpriseService |
There was a problem hiding this comment.
Does this mean that if a community user sets reuseBestSolution to true, the solving process will fail?
| bestSolutionRecaller.setAssertBestScoreIsUnmodified(true); | ||
| } | ||
| if (reuseBestSolution != null && reuseBestSolution) { | ||
| bestSolutionRecaller.setReuseBestSolution(true); |
There was a problem hiding this comment.
| bestSolutionRecaller.setReuseBestSolution(true); | |
| bestSolutionRecaller.setReuseBestSolution(reuseBestSolution != null && reuseBestSolution); |
| bestSolutionRecaller.setAssertShadowVariablesAreNotStale(true); | ||
| bestSolutionRecaller.setAssertBestScoreIsUnmodified(true); | ||
| } | ||
| if (reuseBestSolution != null && reuseBestSolution) { |
There was a problem hiding this comment.
| if (reuseBestSolution != null && reuseBestSolution) { |
| } | ||
| if (reuseBestSolution != null && reuseBestSolution) { | ||
| bestSolutionRecaller.setReuseBestSolution(true); | ||
| } |
| } | ||
|
|
||
| public <Solution_> BestSolutionRecaller<Solution_> buildBestSolutionRecaller(EnvironmentMode environmentMode) { | ||
| public <Solution_> BestSolutionRecaller<Solution_> buildBestSolutionRecaller(EnvironmentMode environmentMode, |
There was a problem hiding this comment.
Please refer to my comment on BestSolutionRecaller. It appears we could pass the lock instance as a parameter here.
| } | ||
|
|
||
| private CompletableFuture<Void> scheduleIntermediateBestSolutionConsumption() { | ||
| private CompletableFuture<Void> scheduleIntermediateBestSolutionConsumption(Lock lock) { |
There was a problem hiding this comment.
I believe here is another place where we should consider keeping the existing contract unchanged. Let's say we can create LockableConsumerSupport that receives or creates its internal lock instance. We could extend ConsumerSupport and override scheduleIntermediateBestSolutionConsumption without changing the existing contract. Does it make sense?
triceo
left a comment
There was a problem hiding this comment.
Folks, we will not be merging this now.
I am not convinced that the problem we're solving warrants this level of complexity. Specifically:
- Growing list of changes that need to be applied, effectively a controlled memory leak.
- Heuristics for the solution sizes where this should kick in, and heuristics for the list size where this should be disabled.
- External configuration for something so low-level, because we need to be able to deal with a situation that the user modifies the best solution while we still need to apply moves on it.
- Lockable event listeners?!
This shows me that the approach selected here is not the right one. Considering that we currently do not feel this pain, and that we have other priorities to tackle, I think we should not be spending more time right now to get this right.
|
I was thinking about this more, and I really think that this should not be merged. My previous objections still stand, but I want to add another one - even if the user is aware of the fact that the best solutions will be updated, there is still no sane way to work with best solution events. If two events can share the same solution, that means the user possibly has two events which influence each other; this behavior is impossible to deal with. If the new event already exists, the old event has the new event's data; this tells me that if we want to keep this mechanism instead of cloning, we need to refactor how best solutions are consumed. I consider this mechanism fundamentally and unfixably incompatible with best solution events. |
Hence why event listeners are lockable; via SolverFactory, nothing changes since event listeners are synchronous (and if they will spawn a new thread, they can implement LockableEventListener instead). Via SolverManager, the event listener threads locks the best solution so the Solver cannot update it (and thus cannot fire additional events) until the event listener finishes. |
But all of this is wrong! With The approach is more obviously wrong on the |


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.