-
Notifications
You must be signed in to change notification settings - Fork 98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add new adaptive termination type #1313
base: main
Are you sure you want to change the base?
feat: add new adaptive termination type #1313
Conversation
The adaptive termination type has the concept of a grace period G and minimum improvement ratio R. The adaptive termination type works as follows. - When in the grace period, do not terminate. - If a non-lowest score level changes at any point, reset the grace period (or enter a new one if the grace period is finished). - After the duration G of the grace period passes, record the difference of final best score and the starting best score as Y_0. If this is 0, terminate. - On each step, calculate the difference between the current best score and the best score G moments ago as Y_n. If Y_n / Y < R (i.e. the score did not improve by at least R% of the improvement in the grace window), terminate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That was quick! Leaving suggestions for improvement.
Maybe we introduce some TRACE-level logging? This termination is doing smart things, and some visibility into the mechanism may be necessary. We should find a decent way of logging the decrease of the ratio - maybe log every time in halves?
Also required:
- Documentation. Preferrably with a visualization as above.
- Naming. (Already started a conversation on Slack.)
- Add this to quickstarts?
public AdaptiveTermination(long gracePeriodMillis, double minimumImprovementRatio) { | ||
this.gracePeriodMillis = gracePeriodMillis; | ||
this.minimumImprovementRatio = minimumImprovementRatio; | ||
this.scoresByTime = new TreeMap<>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was skeptical of this approach, as fast-stepping algorithms will easily put tens of thousands of entries to this map per second, and I was expecting that to be both CPU-intensive and memory-intensive.
However, in the experiments I ran, this did not materialize. Impact of the termination is barely visible in the data. Interesting.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder though if the TreeMap
is bringing any tangible benefits. Consider this:
- We don't actually need sorting, do we? The values are sequential.
- We are forced to box
long
intoLong
, causing some unnecessary GC pressure.
I wonder if we could convert this into List<Score_>
. The index would have to become relative to the last reset, so starting with zero. But it may cause large gaps, and those would be a problem in their own right. So maybe not.
this.scoresByTime = new TreeMap<>(); | ||
} | ||
|
||
private record LevelScoreDiff(boolean harderScoreChanged, double softestScoreDiff) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typically, I would be all for a carrier type like this. In this case, and on the hot path, I'd rather store the two variables individually and avoid allocating instances of the wrapper.
In fact, this is the only thing that actually made a measurable impact in my experiments - LevelScoreDiff
was ~10th most allocated object on the heap.
@Override | ||
@SuppressWarnings("unchecked") | ||
public boolean isSolverTerminated(SolverScope<Solution_> solverScope) { | ||
return isTerminated(System.currentTimeMillis(), (Score_) solverScope.getBestScore()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we should use nanotime instead, and truncate it to millis.
The precision of currentTimeMillis
is questionable, see Javadoc:
Note that while the unit of time of the return value is a millisecond, the granularity of the value depends on the underlying operating system and may be larger. For example, many operating systems measure time in units of tens of milliseconds.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also... should this terminate the solver even in, for example, a CH phase? Arguably it should not, the CH phase generally doesn't improve.
The way how unimproved terminations get around it is by forcing themselves to be only admissible in local search phase. Maybe we do something like that too?
Although I really don't like that, because it causes config pain. (You need to define LS phase, and put the termination inside of it.) Maybe we refactor the terminations a bit, so that it is possible for an unimproved termination to be defined on the solver-level, but never terminate a CH? Would need a discussion, I think.
(My ultimate goal is to eventually have only one termination. This one. And have it be the default, without any configuration. And this quirk is preventing that from happening, bwd compat issues aside.)
|
||
@Override | ||
public double calculateSolverTimeGradient(SolverScope<Solution_> solverScope) { | ||
return -1.0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if there's a way to estimate this based on how close we are to the minimum necessary improvement.
*/ | ||
public static <Score_ extends Score<Score_>> @Nullable LevelScoreDiff between(@NonNull Score_ start, | ||
@NonNull Score_ end) { | ||
var scoreDiffs = end.subtract(start).toLevelDoubles(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Arguably, you can save yourself some of this work. compareTo()
the two scores; if no better, no need to do anything anymore. The comparison works directly on the underlying data types, so no need to convert them to doubles and create a new array of them.
import org.jspecify.annotations.NonNull; | ||
import org.jspecify.annotations.Nullable; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can use @NullMarked
on the class, which means that everything is @NonNull
unless marked as @Nullable
. In my experience, this saves a lot of hassle.
- Use a signal exception to avoid the need for a carrier type, allowing use to return double instead of Double or LevelScoreDiff - Use a pair of ring buffers to represent the TreeMap<> to avoid creating boxed types and to optimize on the fact the entries we are looking for are often near the start, and we are always appending to the end
Quality Gate failedFailed conditions See analysis details on SonarQube Cloud Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE |
var softestLevel = scoreDiffs.length - 1; | ||
for (int i = 0; i < softestLevel; i++) { | ||
if (scoreDiffs[i] != 0.0) { | ||
throw new HardLevelImprovedSignal(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
May I suggest we return an impossible value instead of using exceptions for control flow? Perhaps NaN?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually found using exceptions made the logic a bit cleaner, since there are two separate places the method gets called and need to do the same logic (the difference being in what old score the new score is being compared to). It also forces handling the exception/signal, whereas it can be easy to forget checking an impossible value.
|
||
private static <Score_ extends Score<Score_>> double softImprovement(@NonNull Score_ start, | ||
@NonNull Score_ end) throws HardLevelImprovedSignal { | ||
if (start.equals(end)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can the score be worse here? Arguably, it can - sometimes the solver picks a deteriorating move to try to get out of a local optima.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, it uses best score, not step score. Best score should never decrease.
stepScope.getPhaseScope().getBestScore()
The adaptive termination type has the concept of a grace period G and minimum improvement ratio R.
The adaptive termination type works as follows.
When in the grace period, do not terminate.
If a non-lowest score level changes at any point, reset the grace period (or enter a new one if the grace period is finished).
After the duration G of the grace period passes, record the difference of final best score and the starting best score as Y_0. If this is 0, terminate.
On each step, calculate the difference between the current best score and the best score G moments ago as Y_n. If Y_n / Y < R (i.e. the score did not improve by at least R% of the improvement in the grace window), terminate.
Fixes #1311