perf: update planning list variable elements by a cascade instead of the graph - #2548
perf: update planning list variable elements by a cascade instead of the graph#2548fodzal wants to merge 10 commits into
Conversation
…the graph When a model's list elements only read their chain and, through their inverse, pre-chain declarative variables of their own list entity, and the other entities only reach the elements through the list variable itself, the elements are excluded from the variable reference graph. The graph then only covers the remaining entities and is built by the existing machinery, unchanged and with its full generality; without per-element edges it often has no dynamic edges at all and stays fixed. The elements are updated by a cascade that walks each dirty entity's list from the earliest dirty element, marking the entity's post-chain variables changed when an element changed. Updates alternate the cascade and the graph until neither has work left; an entity whose pre-chain variables just changed is deferred while other entities still need walking, so every element settles in a single computation. This removes the per-element graph nodes, edges and topological order maintenance, which dominate the graph cost in vehicle routing models where elements far outnumber the other entities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@fodzal Thanks for the PR! Looks interesting. FYI This will take us a while to review. We first need to finish some other work in this area, which will likely cause conflicts for you. Once you resolve them, we should be able to review. Please bear with us for a while; I'll let you know when we're ready to move forward. |
There was a problem hiding this comment.
Thanks for the PR @fodzal ; did you intend for this PR to be reviewed? It is still marked as draft/in-progress. Draft PR's don't typically get reviews unless explicitly requested. Comments inline.
| return listVariableDescriptorList.isEmpty() ? null : listVariableDescriptorList.getFirst(); | ||
| } | ||
|
|
||
| public List<ListVariableDescriptor<Solution_>> getListVariableDescriptorList() { |
There was a problem hiding this comment.
Why was this method added? Only a single ListVariable is supported; you can use getListVariableDescriptor() and check for null instead.
There was a problem hiding this comment.
It was there to reject models with multiple list variables.
Removed; the detection now uses getListVariableDescriptor() with a null check.
| for (var descriptor : elementDescriptorList) { | ||
| for (var source : descriptor.getSources()) { | ||
| if (source.parentVariableType() == ParentVariableType.INVERSE) { | ||
| elementReadVariableSet.add(source.variableSourceReferences().get(1).variableMetaModel()); |
There was a problem hiding this comment.
This looks fragile - Use .getFirst().downstreamDeclarativeVariableMetamodel() instead. If downstreamDeclarativeVariableMetamodel is null, then it referencing a non-declarative variable on the inverse (ex: (inverse) vehicle.(planning variable) employee in a mixed model).
There was a problem hiding this comment.
Done, here and in the INVERSE case of the detection in GraphStructure, which now checks
downstreamDeclarativeVariableMetamodel() == null instead of !references.get(1).isDeclarative().
Since the detection rejects inverse sources targeting a non-declarative variable, this spot wraps it in
Objects.requireNonNull.
| if (elementList.isEmpty()) { | ||
| return null; | ||
| } | ||
| return isPreviousDirection ? elementList.get(0) : elementList.get(elementList.size() - 1); |
There was a problem hiding this comment.
Use getFirst()/getLast().
There was a problem hiding this comment.
Done, and swept the rest of the PR's code (including the test domains) for the same pattern.
| * through their inverse, pre-chain declarative variables of their own list entity, | ||
| * and because the other classes only reach the elements through the list variable itself. | ||
| */ | ||
| public record ListElementCascade(Class<?> elementEntityClass) { |
There was a problem hiding this comment.
Do not create a record containing only a single field and not additional methods; just use the type Class<?> directly.
There was a problem hiding this comment.
Done: GraphStructureAndDirection now carries a @Nullable Class<?> cascadedElementClass directly.
| var listVariableDescriptorList = solutionDescriptor.getListVariableDescriptorList(); | ||
| if (listVariableDescriptorList.size() != 1) { |
There was a problem hiding this comment.
Impossible; multiple list variables are not supported; use getListVariableDescriptor and replace this with a null check.
There was a problem hiding this comment.
Done: replaced with getListVariableDescriptor() and a null check.
| var sourceParentMetaModel = source.variableSourceReferences().get(0).variableMetaModel(); | ||
| if (parentMetaModel == null) { | ||
| parentMetaModel = sourceParentMetaModel; | ||
| direction = parentVariableType; |
There was a problem hiding this comment.
Does not properly handle when some variables depend on PREVIOUS, and other various depend on NEXT. Need to do if (direction == null) { direction = parentVariableType; } else if (direction != parentVariableType) { return null; }
There was a problem hiding this comment.
Added the explicit direction check.
Note: the mixed case was already rejected, if only implicitly — a
PREVIOUSsource's first reference is a
@PreviousElementShadowVariableand aNEXTsource's is a@NextElementShadowVariable, so they can never be
the same metamodel and!parentMetaModel.equals(...)already returnednull. The explicit check is clearer,
so it's in.
| return null; | ||
| } | ||
| var hasElementAlignmentKey = elementDescriptorList.stream() | ||
| .anyMatch(descriptor -> descriptor.getAlignmentKeyMap() != null); |
There was a problem hiding this comment.
Could do this check and exit early in the elementEntityClass.isAssignableFrom(entityClass) of the for.
| .map(extractor -> new ArrayList<Object>(extractor.apply(solution))) | ||
| .toList(); | ||
|
|
||
| SolutionManager.updateShadowVariables(solution); |
There was a problem hiding this comment.
FYI, this will use the new graph type (updateShadowVariables creates a ScoreDirector when given a solution).
There was a problem hiding this comment.
Good catch, that made the fixed-point assertion compare the cascade against itself.
The recomputation is now done in two passes:
- the entity-based
SolutionManager.updateShadowVariables(Class, Object...)re-derives the built-in shadow
variables, which are outside the graph by construction; - a graph built with
GraphStructure.ARBITRARYforced explicitly recomputes every declarative variable from
scratch, overwriting whatever the first pass produced for them.
So whichever graph SolutionManager picks internally (today or in the future) the last pass alone decides the
reference values, independently of the graph under test.
| public TestdataMultiEntityChainVehicle(String code, int departureTime) { | ||
| super(code); | ||
| this.departureTime = departureTime; | ||
| // A head vehicle's only source is an empty fact collection, |
There was a problem hiding this comment.
Can you also use a null collection to represent a head vehicle? If not, I recommend adding support for it.
There was a problem hiding this comment.
Yes, the test domain now represents head vehicles with a null collection, so the tests exercise it.
While at it: this comment was wrong (the supplier is triggered at graph construction) and the
pre-initialization it justified turned out to be unnecessary, so both are removed, here and in the other test
domains that copied the pattern.
| } | ||
| Integer base; | ||
| if (previousVisit == null) { | ||
| // No unboxing: previousEndTime may be null while the vehicles' values converge. |
There was a problem hiding this comment.
converge -- what? Is this called multiple times per update?
There was a problem hiding this comment.
Clarified the comment. Two situations, illustrated on chained vehicles A -> B, where B reads A's endTime
through its own previousEndTime:
During an incremental update: once per element supplier
Say a move appends a visit to A while B holds [b1, b2].
- The cascade walks
A's chain, then one topological pass of the inner graph propagates
A.endTime -> B.previousEndTime -> B.endTime. - Since
B.previousEndTimechanged,Bis flagged for a whole-chain walk; but that walk is deferred until the
flagging pass is over, sob1andb2are each computed exactly once, with the settledpreviousEndTime. - The one exception is
B.endTimeitself: the same topological pass already recomputed it through the
previousEndTime -> endTimeedge, beforeB's chain was re-walked, and it is recomputed once more after the
walk — two calls, a bounded residual cost per flagged entity.
ListElementCascadeVariableReferenceGraphTest pins exactly this scenario (calledCount == 1 for the visits,
== 2 for B.endTime), and the class javadoc of ListElementCascadeVariableReferenceGraph now spells it out.
During the initial computation: up to one extra pass per chain level
At construction everything starts from null, so with A -> B -> C:
- the first pass computes
A's chain, butB.previousEndTimeis stillnull.A.endTimewas not known when it
was computed, and that transientnullis exactly what this comment and the(Integer)cast guard against; - the next pass settles
B, thenC.
So B's visits are computed twice at construction and C's three times: a one-off bootstrap cost proportional to
the chain depth, not a per-move cost, and also asserted in that test.
The values are correct before the first move either way; the session constructor iterates to the fixed point when
the score director is created, and the FULL_ASSERT solve tests would catch any pre-move corruption.
- replace the single-field ListElementCascade record with a Class<?> component - use downstreamDeclarativeVariableMetamodel() for inverse sources - add the explicit previous/next direction check - make the fixed-point assertion recompute with a forced arbitrary graph, independent of the graph under test - represent head vehicles with null fact collections in the test domains and drop the unnecessary shadow variable pre-initialization; the tests now pin the real bootstrap cost (one extra pass per vehicle chain level)
|
Thanks a lot for the review; very helpful, and sorry for the draft confusion. A few commits since:
Marking it ready for review. |
Why
Follow-up to the performance discussion in #2357 and to #2506.
The arbitrary variable reference graph is often much slower than what hand-written
variable listeners achieve for the same model: it maintains a node per entity/variable
pair, dynamic edges and an incremental topological order on every move. #1659 closed that
gap for simple structures (
EMPTY,NO_DYNAMIC_EDGES,SINGLE_DIRECTIONAL_PARENT), buta model that falls outside them drops back to the arbitrary graph, and on large problems
that cost dominates solving. In planning list variable models this hits particularly
hard: the elements are typically counted in thousands while the other entities are
counted in hundreds, so the per-element part of the graph is usually the updater's
bottleneck.
The motivating use case is multi-trip vehicle routing that cannot be modeled by simulating
a depot return when capacity is exceeded, for example:
unlike synchronized visits, where a single visit requires two technicians.
Such problems are modeled with one planning entity per trip: vehicle A becomes trips A1
and A2, and A2's first travel leg starts from A1's last visit. The trip's start must
depend on the previous trip's end, and the trip's end on its own visits — a list element
source (#2506) plus a cross-entity reference, which today classifies the whole model as
arbitrary:
In such a model the visits vastly outnumber the vehicles, so nearly all of the arbitrary
graph's nodes, edges and topological-order maintenance is spent on them — while their
dependencies are precisely the ones that need no graph: a visit only reads its chain and
its own vehicle.
What
A narrower fix would extend the
SINGLE_DIRECTIONAL_PARENTdetection to this shape andadd another specialized updater. This PR instead splits the model so that each half is
handled by machinery that is already good at it:
graph and updated by a cascade that walks each dirty entity's list in chain order,
the way a variable listener would;
full generality: any structure, dynamic edges, groups, several entity classes,
dependency loops handled as inconsistency. Without per-element edges the graph is small
and often has no dynamic edges left; the example above gets a fixed graph over the
vehicles.
The detection (
GraphStructure.determineListElementCascade) only inspects the elementclass and the references toward it, and accepts the decomposition when:
previous/next(a singledirection across the model), their own members, or — through their inverse — a
declarative variable of their entity that does not itself depend on the list
(pre-chain);
visits[].xon their own listvariable;
is distinct from the list entity class.
Anything else falls through to the current classification, so unsupported models behave
exactly as they do today.
How
The list entity's declarative variables are classified as pre-chain (readable by
the elements, computed before walking a chain) or post-chain (depending on
visits[].xdirectly or transitively, computed after). Cross-entity references do notpropagate post-chain status: "B starts where A ends" keeps
startTimepre-chain of B.ListElementCascadeVariableReferenceGraphwraps the inner graph and receives allevents. Element source changes accumulate dirty elements; list change events mark the
changed range dirty and the owner's post-chain variables changed, even for an empty
range, since a removal changes the aggregate; a
ChangedVariableNotifierwrapperobserves pre-chain changes during updates and flags the owner for a whole-chain walk.
updateChanged()alternates the cascade and the inner graph until neither has workleft. A walk starts at the earliest dirty element and may terminate early after the
last dirty one once values stop changing. An owner whose pre-chain variables just
changed is deferred while other chains still need walking, so every supplier is
computed once per update; the number of passes is bounded by the depth of inter-entity
dependencies that traverse the lists.
On the example above, an update that touches A's route settles in this order:
When the inner graph marks an owner inconsistent (a dependency loop the solver may
break later), the cascade marks its elements inconsistent instead of computing them,
and recomputes them when consistency returns.
The factory changes are mechanical: the existing graph builders now take the list of
descriptors to cover (defaulting to all of them), and under a cascade the per-element
locator and edges are skipped while the "list changed -> target dirty" processor is
kept.
Notes:
@CascadingUpdateShadowVariable's listener:start at the first changed element, stop early once values settle. What differs is what
triggers and surrounds it. A cascading update shadow variable only reacts to its
element's own list position changes, so a dependency on another entity's variables is
neither declared nor retriggered — the multi-trip model above is not expressible with
it. Here the walk is embedded in the declarative dependency graph: a pre-chain change
re-walks the chain, post-chain variables and downstream entities are ordered by the
graph, and dependency loops are still detected as inconsistency.
SINGLE_DIRECTIONAL_PARENT. With anEMPTYinner graph the wrapper is behaviorallyequivalent to it, so a later unification is possible, but out of scope here.
SolutionDescriptorcurrently guarantees; a guard documents the assumption and fallsback to the current behavior if that restriction is ever lifted.
Testing
GraphStructureTest: accepted shapes (fact-collection chain, plain-factchain,
nextdirection, a three-class model whose depot keeps its own graph) and arejected shape (a fact collection of another vehicle's visits).
creating non-contiguous dirty elements on the same chain, and a pre-chain change
reaching an element that reads it directly while its predecessors are unchanged.
FULL_ASSERT, with constraints reading the shadow variables;after each scenario and each solve, the incrementally maintained values are compared
against a from-scratch
SolutionManager.updateShadowVariablesrecompute, which usesthe arbitrary graph.
ListElementCascadeVariableReferenceGraphTestcounts supplier calls to pin thesingle-computation guarantee, and documents the one residual extra evaluation (the
post-chain variable of an entity whose chain is walked again).
fail-fast.
coretest suite passes.Local search throughput at the motivating scale (579 vehicle trips, 7,000 visits, same
solver configuration and machine within each row):
main(arbitrary graph)The synthetic row reproduces the gap independently of the business model, on this PR's
own fact-chain test domain, from an initialized solution and with graph construction
excluded from the measurement. The unit test domains are far too small to exhibit this
(the gap grows with the element count), which is why they focus on correctness instead.