Fix MergeOptimizer's dead guard against merging destroyed variables - #2355
Conversation
The check meant to stop MergeOptimizer from merging identical nodes whose
outputs are destroyed has been dead code since the Python 3 port:
`flatten(destroy_map.values())` receives a `dict_values` view, which
`pytensor.utils.flatten` does not recurse into (it was a list under
Python 2), so the membership test never succeeded.
Every such doomed merge therefore ran the full replace_all_validate
cycle -- checkpoint, DestroyHandler validation failure ("Multiple
destroyers"), revert -- and MergeFeature re-scheduled the pair after
every subsequent graph change. Graphs with many identical destroyed
nodes hit this ~n^3 times: the gradient of `sum_i params_i[idx_i]`
produces one destroyed zeros-Alloc per term, and a 40-term graph of
that shape spent 294s in rewriting (11,440 failed merge attempts);
with the guard working it compiles in 2.1s with none.
The replacement guard asks the DestroyHandler for transitive destroyers
instead of scanning direct clients' destroy_maps: it also catches
variables destroyed through a view chain, and only skips pairs where
both variables are destroyed (a single-destroyer merge is valid and
still allowed -- as it effectively was for the last decade).
🤦 that's borderline hilarious. Also means we had 0 check coverage.
I don't think this is true at all? Maybe I'm just misreading, trivial case: x = pt.vector()
e1 = pt.exp(x)
e2 = pt.exp(x)
out1 = e[0].set_inplace(0) # destroy e1
out2 = e2[::-1] # consumes e2
function([x], [out1 out2])
TLDR: The code is right (it doesn't reject as it may still be possible to fuse), that sentence itself is wrong (it's not guaranteed that the merging will be feasible).
I worry about the performance of fgraph.has_destroyers. Is that cached? It's definitely better to not try mergings that will be rejected, but OTOH most times it's not this case. The choice here is not the flatten options vs the more comprehensive check, but the best narrow-scope we can do vs the comprehensive check. Just need to look at this with skepticism to see if we still conclude the same. |
|
Awesome find btw! |
fgraph.destroyers() rebuilds the droot map whenever the graph changed since the last query, so per-pair transitive checks could get expensive on merge-heavy graphs. A direct-clients scan is O(clients), catches the pathological case fully (identical destroyed nodes have their destroyer as a direct client), and leaves both single-destroyer merges and the rare destroyed-through-a-view-chain pairs to DestroyHandler validation, which is the only thing that can decide them anyway. Also trim the regression test's comment.
|
You're right on both counts — the sentence was wrong (there is no cheap local rule for the single-destroyer case; validation is the decider) and I've rewritten the description accordingly. On the perf concern: |
| self.noinput_nodes.add(node) | ||
|
|
||
|
|
||
| def _has_direct_destroyer(fgraph, var): |
There was a problem hiding this comment.
inline this function in the apply method (can still be a function), not in the loop of it
There was a problem hiding this comment.
Moved into apply, above the loop.
| g.attach_feature(AssertNoChanges()) | ||
| MergeOptimizer().rewrite(g) | ||
|
|
||
| def test_no_merge_attempt_on_destroyed_variables(self): |
There was a problem hiding this comment.
Can you also add test cases where one is destroyed and the other is not (which CAN be achieved with reordering) and the one where it cannot (the non destroyer needs both the twin and the op that destroys it)?
There was a problem hiding this comment.
unless they already exist elsewhere
There was a problem hiding this comment.
Added both: test_merge_single_destroyer_feasible (merge goes through, reader ordered before the destroyer) and test_merge_single_destroyer_infeasible (reader also depends on the destroyer's result — validation rejects, twins stay distinct; the attempt is made and counted in nb_fail, documenting that single-destroyer pairs are validation's call, not the guard's). Didn't find existing coverage of the MergeOptimizer/DestroyHandler interplay — tests/graph/test_destroyhandler.py tests validation and orderings but never runs the merger.
Move has_direct_destroyer into MergeOptimizer.apply, and cover the two single-destroyer outcomes: mergeable via reordering (reader before destroyer), and rejected by validation when the reader also depends on the destroyer's result.
The bug
MergeOptimizer.applyhas a guard meant to skip merging identical nodes whose outputsare destroyed by inplace clients:
pytensor.utils.flattenonly recurses intotuple | list | set.dict.values()is adict_valuesview, soflattenreturns it wrapped in a single-element list and themembership test can never succeed:
Under Python 2,
dict.values()returned a list and the guard worked. It has been deadcode since the Python 3 port.
Why it matters
With the guard dead, every merge of two destroyed variables runs the full
replace_all_validatecycle — checkpoint,DestroyHandlervalidation failure(
InconsistencyError: Multiple destroyers), revert — andMergeFeaturere-schedules thepair after every subsequent graph change, so the same doomed pair is retried across the
whole rewrite phase.
Graphs with many identical destroyed nodes hit this combinatorially. The canonical shape
is the gradient of
sum_i params_i[idx_i](any additive model over gathered parameterblocks): each term contributes a zeros-
Allocgradient buffer, all identical, and afterthe inplace pass each is destroyed by its own
AdvancedIncSubtensor{inplace}. Failedmerge attempts then grow ~n³:
pytensor.function(mode=NUMBA, main)The blowup is backend-independent (FAST_RUN produces identical failure counts); numba
only makes the wall-clock more visible. A real 142-variable PyMC model spent 40+ minutes
and tens of GB in this storm without finishing.
Repro script
The fix
Skip a scheduled pair when both variables have a direct destroyer:
Merging two destroyed variables always fails validation ("multiple destroyers"), so
these pairs are pure waste. Everything else is left to
DestroyHandlervalidation,which is the only thing that can decide it: a single-destroyer merge may or may not be
feasible (reordering can be possible, or can be proven cyclic — there is no cheap local
rule), and destroyers reachable only through a view chain are rare enough that paying
the occasional validate+revert beats recomputing the transitive droot map per pair. The
check is O(direct clients) and touches no
DestroyHandlerstate.The now-unused
flattenimport is removed.Numerical impact
None by construction: the guard only skips replacements that
DestroyHandlervalidationwould have rejected and reverted. Verified bit-identical logp and grad (max rel. diff
~1e-16, float noise) on a 60-RV hierarchical PyMC model compiled before/after.
Test
TestMergeOptimizer::test_no_merge_attempt_on_destroyed_variables: two identicalop2nodes each destroyed by a
destroy_map={0: [0]}client; assertsnb_fail == 0from theMergeOptimizerprofile and that the twins stay unmerged. Red on main (nb_fail == 2),green with the fix.