You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Summary
Stop the transpose optimizer from pushing a Transpose through an op when that move does not actually cancel a later Transpose.
Stock ORT treats “any downstream Transpose” as a reason to push. Two Transposes cancel only when their perms are inverses (both disappear). They merge when the perms compose into a remaining Transpose. For a shared Transpose, a merge does not remove the original node, so the graph is not simpler and QDQ layout can be broken.
Behavior
Cost model: waive the cost of a Transpose inserted on the output only if a downstream Transpose would cancel the pushed perm, or the incoming Transpose has a single consumer (merge still drops a node). A shared Transpose that would only merge is not pushed.
QDQ guard: if a Transpose already sits in a complete DQ → Transpose → Q unit, do not push it through that Q unless the relocated Transpose would cancel downstream. Dead-end branches (no Transpose) are allowed when cancellation happens on another branch, because the relocated Transpose can stay on those branches.
Test plan
TestSliceSharedTransposeCancel / TestSliceSharedTransposeNoCancel / TestSliceUnsharedTransposeNoCancel
TestQDQSharedTransposeSliceCancel / TestQDQSharedTransposeSliceNoCancel
TestQDQTransposeNotPushedThroughOwnQ
Apply the QDQ guard to every push decision, preview handler perms, reject incomplete consumers, cache walks, and waive output cost only when every pushed output is beneficial.
Co-authored-by: Cursor <cursoragent@cursor.com>
Only waive the inserted output Transpose cost when an output actually leads to a downstream Transpose. The sole-consumer merge exception was being applied even with no downstream Transpose, which pushed Transposes the optimizer should leave alone (TestOnlyOptimizeTowardsTranspose, MobileClip attention fusion).
Restrict the QDQ node unit guard to the default cost path again. Layout transformation returns kPushTranspose to move layout Transposes through Q/DQ on purpose and relies on FixQDQNodeUnits afterwards, so blocking that stopped layout transformation entirely (ConstantFoldTransposeAndSqueezeOutputCorrectness).
Co-authored-by: Cursor <cursoragent@cursor.com>
…ow unpushable paths.
The walk is iterative, previews only handlers that preserve perm (including HandleConcat), and requires a transposable, modifiable consumer plus a rank-adjusted output perm.
Co-authored-by: Cursor <cursoragent@cursor.com>
This walk can report a cancellation through a node that the optimizer will never push through. For example, after pushing a shared Transpose through Slice, let that output feed Add(output, dynamic_same_rank) and then an inverse Transpose. PreviewPushedOutputPerm follows the Add and returns kCancel, but when Add is visited its input cost is -rank + rank == 0, so DefaultCostCheck stops and the newly inserted Transpose remains stranded (and the first rewrite can increase the Transpose count). The same false positive can also let the QDQ guard dismantle a node unit. The walk needs to include downstream push feasibility/cost, or conservatively stop at handlers whose other inputs prevent the push.
Do not waive merge cost for partially shared output Transposes
kept_output_transposes <= 1 counts node outputs, not whether the one surviving output Transpose is shared. With an unshared incoming Transpose, a single-output op whose output fans out to a non-cancelling Transpose and a dead-end branch passes this condition. After the push, the inserted output Transpose must remain for the dead-end branch, while merging on the other branch leaves another Transpose, so the graph still has two Transposes and gains nothing. Only waive the merge cost when every consumer branch reaches a merge/cancellation (or otherwise prove the inserted output Transpose itself is removable).
A shared Transpose through Slice into Add (or Concat) with a same-rank dynamic input was treated as free, then stranded; merge cost is also not waived when the kept output fans out.
Co-authored-by: Cursor <cursoragent@cursor.com>
…e cost only when kept outputs are fully covered or a unique incoming Transpose does not fan out downstream.
Co-authored-by: Cursor <cursoragent@cursor.com>
…st_cast at its call sites.
Guard GetValueInfo before reading a shape so a value without value info makes the preview conservative instead of crashing. Replace the string cache key with a structured key and hasher, and let PreviewPushedOutputPerm take a const NodeRef.
Co-authored-by: Cursor <cursoragent@cursor.com>
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
Fanout traversal can approve unreachable cancellations and unproductive rewrites, while cache invalidation introduces quadratic behavior on deep graphs.
This only detects a merge/dead-end fanout when the Transpose and the other consumer share the same value. For Transpose -> Relu -> Split, with a same-perm Transpose on one Split output and a graph-output branch on the other, the two Split outputs are visited separately, so one sees only has_transpose and the other only has_other. The function returns false, CalculateCost waives Relu's output cost, and the Transpose moves even though Split subsequently refuses the merge and no node is removed. Aggregate transpose and stranded-terminal state across all descendant outputs, not just consumers of one value.
Memo clearing causes quadratic traversal on unary chains
Clearing the entire memo after every successful push makes a long unary chain quadratic. With Transpose -> op1 -> ... -> opN -> inverse Transpose, each op performs a walk over the remaining suffix, then clears all results when the Transpose advances one node, for Θ(N²) consumer visits instead of the previous linear reverse reachability pass. Preserve unaffected downstream results or compute cancellation reachability incrementally so model initialization does not regress on deep graphs.
…os after a push.
Per-value fanout missed Transpose -> Relu -> Split with a merge on one output and a dead-end on the other, and wiping the whole memo after each rewrite made unary chains quadratic.
Co-authored-by: Cursor <cursoragent@cursor.com>
The fanout DFS kept its visited set local to each call, so a unique Transpose ahead of a long chain ending in a same-perm Transpose re-walked the whole suffix after every push. Track 'reaches a Transpose' and 'reaches a stranded terminal' as composable per-value flags in an OptimizerCtx memo that the existing rewrite invalidation clears.
Co-authored-by: Cursor <cursoragent@cursor.com>
The two DFS copies used the same stack, in-progress marker, and rewrite invalidation with only the combine rule different, so a third walk could go call-local again. ExpandPushWalk classifies consumers; RunCachedPushWalk owns the memo.
Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stop the transpose optimizer from pushing a Transpose through an op when that move does not actually cancel a later Transpose.
Stock ORT treats “any downstream Transpose” as a reason to push. Two Transposes cancel only when their perms are inverses (both disappear). They merge when the perms compose into a remaining Transpose. For a shared Transpose, a merge does not remove the original node, so the graph is not simpler and QDQ layout can be broken.
Behavior
Cost model: waive the cost of a Transpose inserted on the output only if a downstream Transpose would cancel the pushed perm, or the incoming Transpose has a single consumer (merge still drops a node). A shared Transpose that would only merge is not pushed.
QDQ guard: if a Transpose already sits in a complete DQ → Transpose → Q unit, do not push it through that Q unless the relocated Transpose would cancel downstream. Dead-end branches (no Transpose) are allowed when cancellation happens on another branch, because the relocated Transpose can stay on those branches.
Test plan

TestSliceSharedTransposeCancel / TestSliceSharedTransposeNoCancel / TestSliceUnsharedTransposeNoCancel
TestQDQSharedTransposeSliceCancel / TestQDQSharedTransposeSliceNoCancel
TestQDQTransposeNotPushedThroughOwnQ