Skip to content

Commit 2ff5d14

Browse files
committed
perf: significantly reduce overhead of Neighborhoods
1 parent a3ed8d0 commit 2ff5d14

29 files changed

+409
-216
lines changed

core/src/main/java/ai/timefold/solver/core/config/solver/PreviewFeature.java

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,7 @@ public enum PreviewFeature {
2424
/**
2525
* Unlike other preview features, Neighborhoods are an active research project.
2626
* It is intended to simplify the creation of custom moves, eventually replacing move selectors.
27-
* The component is under heavy development, entirely undocumented, and many key features are yet to be delivered.
28-
* Neither the API nor the feature set are complete, and any part can change or be removed at any time.
29-
*
30-
* Neighborhoods will eventually stabilize and be promoted from a research project to a true preview feature.
31-
* We only expose it now to be able to use it for experimentation and testing.
32-
* As such, it is an exception to the rule;
33-
* this preview feature is not finished, and it is not yet ready for feedback.
27+
* The component is under development, and many key features are yet to be delivered.
3428
*/
3529
NEIGHBORHOODS
3630

core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ComparisonIndexer.java

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import java.util.Collections;
44
import java.util.Comparator;
5-
import java.util.List;
5+
import java.util.Iterator;
6+
import java.util.Map;
67
import java.util.NavigableMap;
8+
import java.util.NoSuchElementException;
79
import java.util.Objects;
810
import java.util.TreeMap;
911
import java.util.function.Consumer;
@@ -13,6 +15,7 @@
1315
import ai.timefold.solver.core.impl.util.ListEntry;
1416

1517
import org.jspecify.annotations.NullMarked;
18+
import org.jspecify.annotations.Nullable;
1619

1720
@NullMarked
1821
final class ComparisonIndexer<T, Key_ extends Comparable<Key_>>
@@ -138,7 +141,8 @@ private int sizeManyIndexers(Object compositeKey) {
138141
public void forEach(Object compositeKey, Consumer<T> tupleConsumer) {
139142
switch (comparisonMap.size()) {
140143
case 0 -> {
141-
/* Nothing to do. */ }
144+
/* Nothing to do. */
145+
}
142146
case 1 -> forEachSingleIndexer(compositeKey, tupleConsumer);
143147
default -> forEachManyIndexers(compositeKey, tupleConsumer);
144148
}
@@ -164,47 +168,115 @@ private void forEachManyIndexers(Object compositeKey, Consumer<T> tupleConsumer)
164168
}
165169
}
166170

171+
@Override
172+
public Iterator<T> iterator(Object compositeKey) {
173+
return switch (comparisonMap.size()) {
174+
case 0 -> Collections.emptyIterator();
175+
case 1 -> iteratorSingleIndexer(compositeKey);
176+
default -> new DefaultIterator(compositeKey);
177+
};
178+
}
179+
180+
private Iterator<T> iteratorSingleIndexer(Object compositeKey) {
181+
var indexKey = keyRetriever.apply(compositeKey);
182+
var entry = comparisonMap.firstEntry();
183+
if (boundaryReached(entry.getKey(), indexKey)) {
184+
return Collections.emptyIterator();
185+
}
186+
// Boundary condition not yet reached; include the indexer in the range.
187+
return entry.getValue().iterator(compositeKey);
188+
}
189+
167190
@Override
168191
public boolean isEmpty() {
169192
return comparisonMap.isEmpty();
170193
}
171194

172195
@Override
173-
public List<? extends ListEntry<T>> asList(Object compositeKey) {
196+
@Nullable
197+
public ListEntry<T> get(Object compositeKey, int index) {
174198
return switch (comparisonMap.size()) {
175-
case 0 -> Collections.emptyList();
176-
case 1 -> asListSingleIndexer(compositeKey);
177-
default -> asListManyIndexers(compositeKey);
199+
case 0 -> null;
200+
case 1 -> getSingleIndexer(compositeKey, index);
201+
default -> getManyIndexers(compositeKey, index);
178202
};
179203
}
180204

181-
private List<? extends ListEntry<T>> asListSingleIndexer(Object compositeKey) {
205+
private @Nullable ListEntry<T> getSingleIndexer(Object compositeKey, int index) {
182206
var indexKey = keyRetriever.apply(compositeKey);
183207
var entry = comparisonMap.firstEntry();
184-
return boundaryReached(entry.getKey(), indexKey) ? Collections.emptyList() : entry.getValue().asList(compositeKey);
208+
return boundaryReached(entry.getKey(), indexKey) ? null : entry.getValue().get(compositeKey, index);
185209
}
186210

187-
@SuppressWarnings("unchecked")
188-
private List<? extends ListEntry<T>> asListManyIndexers(Object compositeKey) {
189-
// The index backend's asList() may take a while to build.
190-
// At the same time, the elements in these lists will be accessed randomly.
191-
// Therefore we build this abstraction to avoid building unnecessary lists that would never get accessed.
192-
var result = new ComposingList<ListEntry<T>>();
211+
private @Nullable ListEntry<T> getManyIndexers(Object compositeKey, int index) {
212+
var seenCount = 0;
193213
var indexKey = keyRetriever.apply(compositeKey);
194214
for (var entry : comparisonMap.entrySet()) {
195215
if (boundaryReached(entry.getKey(), indexKey)) {
196-
return result;
216+
return null;
197217
} else { // Boundary condition not yet reached; include the indexer in the range.
198218
var value = entry.getValue();
199-
result.addSubList(() -> (List<ListEntry<T>>) value.asList(compositeKey), value.size(compositeKey));
219+
var size = value.size(compositeKey);
220+
if (seenCount + size > index) {
221+
return value.get(compositeKey, index - seenCount);
222+
}
223+
seenCount += size;
200224
}
201225
}
202-
return result;
226+
return null;
203227
}
204228

205229
@Override
206230
public String toString() {
207231
return "size = " + comparisonMap.size();
208232
}
209233

234+
private final class DefaultIterator implements Iterator<T> {
235+
236+
private final Object compositeKey;
237+
private final Key_ indexKey;
238+
private final Iterator<Map.Entry<Key_, Indexer<T>>> indexerIterator = comparisonMap.entrySet().iterator();
239+
private @Nullable Iterator<T> downstreamIterator = null;
240+
private @Nullable T next = null;
241+
242+
public DefaultIterator(Object compositeKey) {
243+
this.compositeKey = compositeKey;
244+
this.indexKey = keyRetriever.apply(compositeKey);
245+
}
246+
247+
@Override
248+
public boolean hasNext() {
249+
if (next != null) {
250+
return true;
251+
}
252+
if (downstreamIterator != null && downstreamIterator.hasNext()) {
253+
next = downstreamIterator.next();
254+
return true;
255+
}
256+
while (indexerIterator.hasNext()) {
257+
var entry = indexerIterator.next();
258+
if (boundaryReached(entry.getKey(), indexKey)) {
259+
return false;
260+
}
261+
// Boundary condition not yet reached; include the indexer in the range.
262+
downstreamIterator = entry.getValue().iterator(compositeKey);
263+
if (downstreamIterator.hasNext()) {
264+
next = downstreamIterator.next();
265+
return true;
266+
}
267+
}
268+
return false;
269+
}
270+
271+
@Override
272+
public T next() {
273+
if (!hasNext()) {
274+
throw new NoSuchElementException();
275+
}
276+
var result = next;
277+
next = null;
278+
return result;
279+
}
280+
}
281+
210282
}

core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsIndexer.java

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import java.util.Collections;
44
import java.util.HashMap;
5-
import java.util.List;
5+
import java.util.Iterator;
66
import java.util.Map;
77
import java.util.Objects;
88
import java.util.function.Consumer;
@@ -11,6 +11,7 @@
1111
import ai.timefold.solver.core.impl.util.ListEntry;
1212

1313
import org.jspecify.annotations.NullMarked;
14+
import org.jspecify.annotations.Nullable;
1415

1516
@NullMarked
1617
final class EqualsIndexer<T, Key_> implements Indexer<T> {
@@ -108,18 +109,29 @@ public void forEach(Object compositeKey, Consumer<T> tupleConsumer) {
108109
}
109110

110111
@Override
111-
public boolean isEmpty() {
112-
return downstreamIndexerMap.isEmpty();
112+
public Iterator<T> iterator(Object compositeKey) {
113+
Key_ indexKey = keyRetriever.apply(compositeKey);
114+
Indexer<T> downstreamIndexer = downstreamIndexerMap.get(indexKey);
115+
if (downstreamIndexer == null) {
116+
return Collections.emptyIterator();
117+
}
118+
return downstreamIndexer.iterator(compositeKey);
113119
}
114120

115121
@Override
116-
public List<? extends ListEntry<T>> asList(Object compositeKey) {
122+
@Nullable
123+
public ListEntry<T> get(Object compositeKey, int index) {
117124
Key_ indexKey = keyRetriever.apply(compositeKey);
118125
Indexer<T> downstreamIndexer = downstreamIndexerMap.get(indexKey);
119126
if (downstreamIndexer == null) {
120-
return Collections.emptyList();
127+
return null;
121128
}
122-
return downstreamIndexer.asList(compositeKey);
129+
return downstreamIndexer.get(compositeKey, index);
130+
}
131+
132+
@Override
133+
public boolean isEmpty() {
134+
return downstreamIndexerMap.isEmpty();
123135
}
124136

125137
@Override

core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/Indexer.java

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
package ai.timefold.solver.core.impl.bavet.common.index;
22

3-
import java.util.List;
3+
import java.util.Iterator;
44
import java.util.function.Consumer;
55

66
import ai.timefold.solver.core.impl.bavet.common.tuple.TupleState;
77
import ai.timefold.solver.core.impl.util.ListEntry;
88

99
import org.jspecify.annotations.NullMarked;
10+
import org.jspecify.annotations.Nullable;
1011

1112
/**
1213
* An indexer for entity or fact {@code X},
@@ -35,18 +36,11 @@ public sealed interface Indexer<T>
3536

3637
void forEach(Object compositeKey, Consumer<T> tupleConsumer);
3738

38-
boolean isEmpty();
39+
Iterator<T> iterator(Object compositeKey);
40+
41+
@Nullable
42+
ListEntry<T> get(Object compositeKey, int index);
3943

40-
/**
41-
* Returns all entries for the given composite key as a list.
42-
* The index must not be modified while iterating over the returned list.
43-
* If the index is modified, a new instance of this list must be retrieved;
44-
* the previous instance is no longer valid and its behavior is undefined.
45-
*
46-
* @param compositeKey the composite key
47-
* @return all entries for a given composite key;
48-
* the caller must not modify the list
49-
*/
50-
List<? extends ListEntry<T>> asList(Object compositeKey);
44+
boolean isEmpty();
5145

5246
}
Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
package ai.timefold.solver.core.impl.bavet.common.index;
22

3-
import java.util.List;
4-
5-
import ai.timefold.solver.core.impl.util.ListEntry;
6-
73
import org.jspecify.annotations.NullMarked;
84

95
/**
@@ -18,10 +14,4 @@ public sealed interface IndexerBackend<T>
1814
extends Indexer<T>
1915
permits RandomAccessIndexerBackend, LinkedListIndexerBackend {
2016

21-
@Override
22-
default List<? extends ListEntry<T>> asList(Object compositeKey) {
23-
throw new UnsupportedOperationException("Indexer backend (%s) does not support random access."
24-
.formatted(this.getClass().getSimpleName()));
25-
}
26-
2717
}

core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListIndexerBackend.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package ai.timefold.solver.core.impl.bavet.common.index;
22

3+
import java.util.Iterator;
34
import java.util.function.Consumer;
45

56
import ai.timefold.solver.core.impl.util.ElementAwareLinkedList;
@@ -37,6 +38,16 @@ public void forEach(Object compositeKey, Consumer<T> tupleConsumer) {
3738
tupleList.forEach(tupleConsumer);
3839
}
3940

41+
@Override
42+
public Iterator<T> iterator(Object compositeKey) {
43+
return tupleList.iterator();
44+
}
45+
46+
@Override
47+
public ListEntry<T> get(Object compositeKey, int index) {
48+
throw new UnsupportedOperationException(); // Random access uses a different backend.
49+
}
50+
4051
@Override
4152
public boolean isEmpty() {
4253
return tupleList.size() == 0;

core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RandomAccessIndexerBackend.java

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package ai.timefold.solver.core.impl.bavet.common.index;
22

3-
import java.util.List;
3+
import java.util.Iterator;
44
import java.util.function.Consumer;
55

66
import ai.timefold.solver.core.impl.util.ElementAwareArrayList;
@@ -41,13 +41,18 @@ public void forEach(Object compositeKey, Consumer<T> tupleConsumer) {
4141
}
4242

4343
@Override
44-
public boolean isEmpty() {
45-
return tupleList.isEmpty();
44+
public Iterator<T> iterator(Object compositeKey) {
45+
return tupleList.iterator();
4646
}
4747

4848
@Override
49-
public List<ElementAwareArrayList.Entry<T>> asList(Object compositeKey) {
50-
return tupleList.asList();
49+
public ListEntry<T> get(Object compositeKey, int index) {
50+
return tupleList.get(index);
51+
}
52+
53+
@Override
54+
public boolean isEmpty() {
55+
return tupleList.isEmpty();
5156
}
5257

5358
@Override

core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ private LocalSearchDecider<Solution_> buildDecider(HeuristicConfigPolicy<Solutio
7272
PhaseTermination<Solution_> phaseTermination) {
7373
var neighborhoodsEnabled = phaseConfigPolicy.isPreviewFeatureEnabled(PreviewFeature.NEIGHBORHOODS);
7474
var neighborhoodProviderClass = phaseConfig.<Solution_> getNeighborhoodProviderClass();
75-
if (phaseConfigPolicy.isPreviewFeatureEnabled(PreviewFeature.NEIGHBORHOODS)) {
75+
if (neighborhoodsEnabled) {
7676
if (neighborhoodProviderClass == null) {
7777
// Neighborhoods are enabled, but no provider was specified: use the default one.
7878
neighborhoodProviderClass = (Class) DefaultNeighborhoodProvider.class;

0 commit comments

Comments
 (0)