Skip to content

Commit 64f2cf0

Browse files
committed
perf: significantly reduce overhead of Neighborhoods
1 parent 9f26f36 commit 64f2cf0

29 files changed

+413
-213
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: 91 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,117 @@ 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+
public ListEntry<T> get(Object compositeKey, int index) {
174197
return switch (comparisonMap.size()) {
175-
case 0 -> Collections.emptyList();
176-
case 1 -> asListSingleIndexer(compositeKey);
177-
default -> asListManyIndexers(compositeKey);
198+
case 0 -> throw new IndexOutOfBoundsException("Index: " + index);
199+
case 1 -> getSingleIndexer(compositeKey, index);
200+
default -> getManyIndexers(compositeKey, index);
178201
};
179202
}
180203

181-
private List<? extends ListEntry<T>> asListSingleIndexer(Object compositeKey) {
204+
private ListEntry<T> getSingleIndexer(Object compositeKey, int index) {
182205
var indexKey = keyRetriever.apply(compositeKey);
183206
var entry = comparisonMap.firstEntry();
184-
return boundaryReached(entry.getKey(), indexKey) ? Collections.emptyList() : entry.getValue().asList(compositeKey);
207+
if (boundaryReached(entry.getKey(), indexKey)) {
208+
throw new IndexOutOfBoundsException("Index: " + index);
209+
}
210+
return entry.getValue().get(compositeKey, index);
185211
}
186212

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>>();
213+
private ListEntry<T> getManyIndexers(Object compositeKey, int index) {
214+
var seenCount = 0;
193215
var indexKey = keyRetriever.apply(compositeKey);
194216
for (var entry : comparisonMap.entrySet()) {
195217
if (boundaryReached(entry.getKey(), indexKey)) {
196-
return result;
218+
break;
197219
} else { // Boundary condition not yet reached; include the indexer in the range.
198220
var value = entry.getValue();
199-
result.addSubList(() -> (List<ListEntry<T>>) value.asList(compositeKey), value.size(compositeKey));
221+
var size = value.size(compositeKey);
222+
if (seenCount + size > index) {
223+
return value.get(compositeKey, index - seenCount);
224+
}
225+
seenCount += size;
200226
}
201227
}
202-
return result;
228+
throw new IndexOutOfBoundsException("Index: " + index);
203229
}
204230

205231
@Override
206232
public String toString() {
207233
return "size = " + comparisonMap.size();
208234
}
209235

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

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

Lines changed: 16 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;
@@ -108,18 +108,28 @@ public void forEach(Object compositeKey, Consumer<T> tupleConsumer) {
108108
}
109109

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

115120
@Override
116-
public List<? extends ListEntry<T>> asList(Object compositeKey) {
121+
public ListEntry<T> get(Object compositeKey, int index) {
117122
Key_ indexKey = keyRetriever.apply(compositeKey);
118123
Indexer<T> downstreamIndexer = downstreamIndexerMap.get(indexKey);
119124
if (downstreamIndexer == null) {
120-
return Collections.emptyList();
125+
throw new IndexOutOfBoundsException("Index: " + index);
121126
}
122-
return downstreamIndexer.asList(compositeKey);
127+
return downstreamIndexer.get(compositeKey, index);
128+
}
129+
130+
@Override
131+
public boolean isEmpty() {
132+
return downstreamIndexerMap.isEmpty();
123133
}
124134

125135
@Override

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

Lines changed: 13 additions & 11 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.bavet.common.tuple.TupleState;
@@ -35,18 +35,20 @@ public sealed interface Indexer<T>
3535

3636
void forEach(Object compositeKey, Consumer<T> tupleConsumer);
3737

38-
boolean isEmpty();
38+
Iterator<T> iterator(Object compositeKey);
3939

4040
/**
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
41+
* Gets the entry at the given position for the given composite key.
42+
*
43+
* @param compositeKey composite key uniquely identifying the backend or a set of backends
44+
* @param index the requested position in the index
45+
* @return the entry at the given index for the given composite key
46+
* @throws IndexOutOfBoundsException if the position is out of bounds;
47+
* that is, if the index is negative or greater than or equal to {@link #size(Object)}
48+
* for the given composite key
4949
*/
50-
List<? extends ListEntry<T>> asList(Object compositeKey);
50+
ListEntry<T> get(Object compositeKey, int index);
51+
52+
boolean isEmpty();
5153

5254
}
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/neighborhood/stream/BiRandomMoveIterator.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
package ai.timefold.solver.core.impl.neighborhood.stream;
22

33
import java.util.ArrayList;
4-
import java.util.Collections;
54
import java.util.Iterator;
6-
import java.util.Map;
75
import java.util.NoSuchElementException;
86
import java.util.Objects;
97
import java.util.Random;
108

119
import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple;
1210
import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DefaultUniqueRandomSequence;
1311
import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.UniqueRandomSequence;
14-
import ai.timefold.solver.core.impl.util.CollectionUtils;
1512
import ai.timefold.solver.core.preview.api.move.Move;
1613

1714
import org.jspecify.annotations.NullMarked;
@@ -68,16 +65,15 @@ final class BiRandomMoveIterator<Solution_, A, B> implements Iterator<Move<Solut
6865

6966
// Fields required for iteration.
7067
private final DefaultUniqueRandomSequence<UniTuple<A>> leftTupleSequence;
71-
private final Map<UniTuple<A>, UniqueRandomSequence<UniTuple<B>>> rightTupleSequenceMap;
68+
private final int rightSequenceStoreIndex;
7269
private @Nullable Move<Solution_> nextMove;
7370

7471
public BiRandomMoveIterator(BiMoveStreamContext<Solution_, A, B> context, Random workingRandom) {
7572
this.context = Objects.requireNonNull(context);
7673
this.workingRandom = Objects.requireNonNull(workingRandom);
7774
var leftDatasetInstance = context.getLeftDatasetInstance();
75+
this.rightSequenceStoreIndex = leftDatasetInstance.getRightSequenceStoreIndex();
7876
this.leftTupleSequence = leftDatasetInstance.buildRandomSequence();
79-
this.rightTupleSequenceMap = leftTupleSequence.isEmpty() ? Collections.emptyMap()
80-
: CollectionUtils.newIdentityHashMap(leftDatasetInstance.size());
8177
}
8278

8379
private UniqueRandomSequence<UniTuple<B>> computeRightSequence(UniTuple<A> leftTuple) {
@@ -121,7 +117,11 @@ Please refactor your code (%s) to use the new Move API."""
121117

122118
private void pickNextMove(UniqueRandomSequence.SequenceElement<UniTuple<A>> leftElement) {
123119
var leftTuple = leftElement.value();
124-
var rightTupleSequence = rightTupleSequenceMap.computeIfAbsent(leftTuple, this::computeRightSequence);
120+
var rightTupleSequence = (UniqueRandomSequence<UniTuple<B>>) leftTuple.getStore(rightSequenceStoreIndex);
121+
if (rightTupleSequence == null) {
122+
rightTupleSequence = computeRightSequence(leftTuple);
123+
leftTuple.setStore(rightSequenceStoreIndex, rightTupleSequence);
124+
}
125125
var remove = false;
126126
if (rightTupleSequence.isEmpty()) {
127127
remove = true;
@@ -141,7 +141,7 @@ private void pickNextMove(UniqueRandomSequence.SequenceElement<UniTuple<A>> left
141141
}
142142
if (remove) {
143143
leftTupleSequence.remove(leftElement.index());
144-
rightTupleSequenceMap.remove(leftTuple);
144+
leftTuple.setStore(rightSequenceStoreIndex, null);
145145
}
146146
}
147147

0 commit comments

Comments
 (0)