Skip to content

Fix MergeOptimizer's dead guard against merging destroyed variables - #2355

Merged
ricardoV94 merged 3 commits into
pymc-devs:mainfrom
velochy:fix-merge-destroyed-guard
Aug 14, 2026
Merged

Fix MergeOptimizer's dead guard against merging destroyed variables#2355
ricardoV94 merged 3 commits into
pymc-devs:mainfrom
velochy:fix-merge-destroyed-guard

Conversation

@velochy

@velochy velochy commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

The bug

MergeOptimizer.apply has a guard meant to skip merging identical nodes whose outputs
are destroyed by inplace clients:

if any(
    i in flatten(c.op.destroy_map.values())
    for c, i in clients
    if c.op.destroy_map
):
    continue

pytensor.utils.flatten only recurses into tuple | list | set. dict.values() is a
dict_values view, so flatten returns it wrapped in a single-element list and the
membership test can never succeed:

>>> flatten({0: [0]}.values())
[dict_values([[0]])]
>>> 0 in flatten({0: [0]}.values())
False

Under Python 2, dict.values() returned a list and the guard worked. It has been dead
code since the Python 3 port.

Why it matters

With the guard dead, every merge of two destroyed variables runs the full
replace_all_validate cycle — checkpoint, DestroyHandler validation failure
(InconsistencyError: Multiple destroyers), revert — and MergeFeature re-schedules the
pair 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 parameter
blocks): each term contributes a zeros-Alloc gradient buffer, all identical, and after
the inplace pass each is destroyed by its own AdvancedIncSubtensor{inplace}. Failed
merge attempts then grow ~n³:

n terms failed merge attempts pytensor.function (mode=NUMBA, main) fixed
10 210 0.6 s
20 1,520 4.4 s 1.3 s (0 failures)
40 11,440 294.5 s 2.1 s (0 failures)

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
import numpy as np, pytensor, pytensor.tensor as pt

n, N, L, K = 40, 5232, 8, 20
rng = np.random.default_rng(0)
x = pt.dvector("x")
mu = pt.zeros((N, K))
for i in range(n):
    block = x[i*L*K:(i+1)*L*K].reshape((L, K))
    mu = mu + block[pt.constant(rng.integers(0, L, N))]
loss = pt.special.log_softmax(mu, axis=-1)[
    pt.arange(N), pt.constant(rng.integers(0, K, N))].sum()
g = pytensor.grad(loss, x)
f = pytensor.function([x], [loss, g], mode="NUMBA")   # 294s before, 2.1s after

The fix

Skip a scheduled pair when both variables have a direct destroyer:

if _has_direct_destroyer(fgraph, pairs[0][0]) and _has_direct_destroyer(
    fgraph, pairs[0][1]
):
    continue

Merging two destroyed variables always fails validation ("multiple destroyers"), so
these pairs are pure waste. Everything else is left to DestroyHandler validation,
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 DestroyHandler state.

The now-unused flatten import is removed.

Numerical impact

None by construction: the guard only skips replacements that DestroyHandler validation
would 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 identical op2
nodes each destroyed by a destroy_map={0: [0]} client; asserts nb_fail == 0 from the
MergeOptimizer profile and that the twins stay unmerged. Red on main (nb_fail == 2),
green with the fix.

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).
@ricardoV94

ricardoV94 commented Aug 14, 2026

Copy link
Copy Markdown
Member

Under Python 2, dict.values() returned a list and the guard worked. It has been dead
code since the Python 3 port.

🤦 that's borderline hilarious. Also means we had 0 check coverage.

A single-destroyer merge is valid (the destroyer migrates to the surviving variable) and stays allowed

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])

e1 and e2 can't be merged, our out2 would be corrupted.

out2 is a view, so it makes it obviously wrong, whereas if it's not a view it may still be possible to reorder out2 before out1, but this may create a cycle and be proven invalid. The point is, there's no local trivial rule to guarantee a merge is valid that's assymptotically cheaper than what the DestroyHandler / rejection mechanism ultimately runs. We can just try to get less false positives.

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).

also catches variables destroyed through a view chain, which a direct-clients destroy_map scan cannot see;

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.

Comment thread tests/graph/rewriting/test_basic.py Outdated
@ricardoV94

Copy link
Copy Markdown
Member

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.
@velochy

velochy commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

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: fgraph.destroyers() is only cached until the next graph change (stale_droot), so per-pair transitive checks interleaved with successful merges would recompute the droot map repeatedly. Switched to a direct-clients scan (_has_direct_destroyer, O(clients), no DestroyHandler state touched), applied only in the both-destroyed case — which is the always-rejected one, and the one the pathological graphs hit: their destroyer is a direct client, so the storm benchmark is unchanged (n=40: 294.5s → 2.3s, 0 failed merges). View-chain-only destroyers and single-destroyer pairs fall through to validation as before.

Comment thread pytensor/graph/rewriting/basic.py Outdated
self.noinput_nodes.add(node)


def _has_direct_destroyer(fgraph, var):

@ricardoV94 ricardoV94 Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline this function in the apply method (can still be a function), not in the loop of it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved into apply, above the loop.

g.attach_feature(AssertNoChanges())
MergeOptimizer().rewrite(g)

def test_no_merge_attempt_on_destroyed_variables(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unless they already exist elsewhere

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@ricardoV94
ricardoV94 merged commit 7c57c53 into pymc-devs:main Aug 14, 2026
67 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants