refactor: introduce incremental constraint collectors for improved performance#2270
refactor: introduce incremental constraint collectors for improved performance#2270triceo wants to merge 18 commits into
Conversation
3e5d3e0 to
a69c7c2
Compare
# Conflicts: # core/src/main/java/ai/timefold/solver/core/impl/score/stream/collector/ListUndoableActionable.java
|
@Christopher-Chianelli Ready for review now. I wasn't able to keep the uni changes separate from the bi/tri/quad, so this PR is large. But if you review the uni parts, you can just skim the rest; they are mechanical copies with updates for the higher arities. |
|
It would be great if we had |
|
@triceo GitHub still not allowing me to create any review comments, so I putting my review comments directly on the the PR. UniConstraintCollector - Does a user need to implement both |
/**
* <li>{@link #add(Object)} will be called externally exactly once, when the value enters the group.
* An instance of {@link UniConstraintCollectorAccumulatedValue} will only be created if there is a value to add.</li>
* /
private final UniConstraintCollectorAccumulatedValue<A> innerValue;
private boolean active = false;
AccumulatedValue(ResultContainer_ container) {
this.innerValue = innerIncremental.intoGroup(container);
}
@Override
public void add(A a) {
if (!predicate.test(a)) {
return;
}
active = true;
innerValue.add(a);
}
@Override
public void update(A a) {
boolean nowActive = predicate.test(a);
if (active && nowActive) {
innerValue.update(a);
} else if (active) {
active = false;
innerValue.remove();
} else if (nowActive) {
active = true;
innerValue.add(a);
}
}i.e. |
They can, but they will not be. That is a contract that we guarantee. |
Something like this would cause constraintFactory.forEach(Employee.class)
.join(Shift.class, equal(id(), Shift::getEmployee)
// Employee has an InverseCollectionShadowVariable to get shift count
.groupBy((employee, shift) -> employee, conditionally((employee, shift) -> employee.getShiftCount() > 5, sum((employee, shift) -> shift.getDuration())))
.filter((employee, hours) -> hours != null && hours > 24)
.penalize(HardSoftScore.ONE_HARD, (employee, hours) -> hours - 24)
.asConstraint("Overworked employee")In particular, the
i.e. this is explictly allowed: |
Good point. For collectors which contain other collectors (and only for them), the contract as specified doesn't actually hold. Two ways out of this:
Since we want to prevent unnecessary garbage from being collected, the second option is probably the way to go. |
User typically only implements one, and uses the isIncremental() method to say which they implemented. Our own default collectors indeed implement both, and only for backwards compatibility reasons - if for whatever reason the user decided to call the old non-incremental collector manually, they'd get an exception if they were not implemented, breaking backwards compatibility. |
|
@Christopher-Chianelli I have resolved your comments so far, but I feel as if the review wasn't yet complete. In addition to whatever you will to point out, I'm looking for a discussion of the following points specifically:
|
I am still going through the review; keeping context and making notes for a 8000+ line changes is not trivial, even if most of the changes are mechanical.
Slot-based? The old collector API use a global state, whereas this uses a per-tuple state.
Create a sealed interface
|
| cachedItem = item; | ||
| cachedKey = newKey; | ||
| cachedInnerMap = state.propertyToItemCountMap.computeIfAbsent(newKey, ignored -> new LinkedHashMap<>()); | ||
| cachedCount = cachedInnerMap.computeIfAbsent(item, ignored -> new MutableInt()); |
There was a problem hiding this comment.
Does cachedInnerMap only have one key at a time? removedMapped() is always called before this. It seems you are using a Map strictly for its entry, so you can have a (value, count) tuple.
|


Still a lot of work to do, but early PoCs look promising.
This benchmark run proves there are no regressions from adding the incremental support to the GroupNode:
https://github.com/TimefoldAI/timefold-solver-benchmarks/actions/runs/25095715799/job/73533079940
This benchmark proves no regressions after the new collector implementations were integrated:
https://github.com/TimefoldAI/timefold-solver-benchmarks/actions/runs/25427319378
There are small performance improvements, but the most expensive collectors are not actually part of those benchmarks, so the biggest benefit of this PR does not show up there.