ONNX Runtime PR #32038: Why Overlapping Fusion Candidates Produced an Invalid Graph


ONNX Runtime PR #32038 fixes a graph transformer that could accept a second fusion candidate containing nodes already claimed by an earlier fusion.

The first fusion had not physically removed its nodes yet. It had only placed them in a deferred removal list.

A later traversal could therefore see those same nodes, assign one of them a different role, construct another fused operator from the wrong inputs, and schedule one node for removal twice.

The local pattern still looked recognizable. Its ownership state made it invalid.

The intended fusion pattern

MatMulIntegerToFloatFusion looks for a quantized matrix multiplication followed by conversion and scaling.

A simplified form is:

A ───────────────┐
                 │
B ────────→ MatMulInteger
                 │
                 ▼
                Cast ──────────────┐
                                   │
A scale ──┐                        │
          ├─→ Mul ─────────────→ Mul ──→ output
B scale ──┘

The first Mul combines the two scale values.

The second Mul applies that scale product to the floating-point result of MatMulInteger → Cast.

The transformer replaces this subgraph with one contrib operator:

A
B
A scale
B scale
zero points, if present
bias, if present
        │
        ▼
MatMulIntegerToFloat
        │
        ▼
      output

For a candidate to be valid, its Mul parent must be the node that combines the two scale inputs.

A parent being a Mul is not sufficient by itself.

The regression graph created an accidental second match

The added test constructs two quantized matrix multiplication paths.

The first one has the expected scale multiplication:

scale_product =
    Mul(scale_1, scale_2)

first_result =
    Mul(
        Cast(MatMulInteger_1),
        scale_product
    )

A second path then consumes that result:

output =
    Mul(
        Cast(MatMulInteger_2),
        first_result
    )

The final Mul has:

one Cast parent

and

one Mul parent

That is enough to resemble the fusion pattern structurally.

The problem is the identity of its Mul parent.

first_result is not a multiplication of two scale values. It is the fully scaled result of the first quantized matrix multiplication.

Its inputs are:

Cast(MatMulInteger_1)

and

scale_product

Those are not the two scale inputs required by the second fusion.

Deferred deletion left claimed nodes visible

The old transformer walked a topological list and collected every successfully matched node in nodes_to_remove.

It did not remove those nodes after each fusion.

The general sequence was:

find candidate
        ↓
create fused node
        ↓
append old nodes to removal list
        ↓
continue scanning original graph
        ↓
remove all scheduled nodes

Deferring deletion can simplify graph traversal. The list being traversed does not change underneath the current loop.

It also creates an obligation: once a candidate has been accepted, its nodes must no longer be available to another candidate in the same pass.

The old implementation did not record that reservation separately.

After the first fusion was created, these nodes were scheduled for deletion:

MatMulInteger_1
Cast_1
scale_product
first_result

They still existed in the graph while the loop examined the final Mul.

The final Mul found:

Cast_2
→ valid-looking MatMulInteger parent

first_result
→ valid-looking Mul parent

The matcher proceeded even though first_result already belonged to the first accepted fusion.

The second fused node received the wrong scale inputs

When creating MatMulIntegerToFloat, the transformer took the two inputs of mul_node_right and used them as the fused operator’s scale inputs.

For a normal match, that is correct:

mul_node_right inputs:
    A scale
    B scale

For the overlapping match, mul_node_right was first_result.

Its inputs were instead:

Cast_1 output

scale_product output

The second fused operator would therefore be constructed as though those two values were its independent scale inputs.

The first candidate had changed the logical meaning of that node before the node was physically removed. The second candidate relied only on the still-visible graph shape.

The resulting transformation also placed first_result in the removal list twice:

first fusion:
    first_result is its outer Mul

second fusion:
    first_result is its scale-side Mul

The same graph node had acquired two incompatible roles.

The fix reserves nodes as soon as a fusion is accepted

The merged implementation adds two collections:

InlinedVector<NodeIndex> nodes_to_remove;
InlinedHashSet<NodeIndex> node_indices_to_remove;

The vector retains the eventual removal sequence.

The set represents ownership during candidate discovery.

Before accepting another candidate, the transformer checks its four central nodes:

if (node_indices_to_remove.contains(matmulinteger_node.Index()) ||
    node_indices_to_remove.contains(cast_node.Index()) ||
    node_indices_to_remove.contains(mul_node_right.Index()) ||
    node_indices_to_remove.contains(mul_node.Index())) {
  continue;
}

A candidate sharing any of those nodes with an earlier accepted fusion is skipped.

For the regression graph:

first fusion accepted
        ↓
first_result index enters claimed-node set
        ↓
second candidate identifies first_result as mul_node_right
        ↓
set membership check succeeds
        ↓
second fusion skipped

The final Mul remains in the graph and consumes the output of the first fused operator.

That is the valid transformed form:

MatMulIntegerToFloat_1
        │
        ▼
   first_result
        │
        ├──────────────┐
                       ▼
MatMulInteger_2 → Cast_2 → Mul → output

One valid pattern is fused. The overlapping candidate remains unfused.

Node indices also make deferred removal safer

The previous removal vector stored references to Node objects:

InlinedVector<std::reference_wrapper<Node>>

That representation assumes every referenced node remains alive until its entry is processed.

A duplicated entry breaks that assumption.

Once the first entry removes the node, another stored reference to the same object may no longer refer to live node storage.

The patch stores stable node identities instead:

InlinedVector<NodeIndex>

At deletion time, it resolves each identity through the graph:

Node* node = graph.GetNode(node_index);
ORT_ENFORCE(
    node != nullptr,
    "Node scheduled for removal was already removed.");

This does not replace the overlap check. It protects a different boundary.

The set prevents legitimate control flow from scheduling overlapping removals.

The index lookup turns any future violation of that invariant into an explicit failure instead of continuing through an invalid stored reference.

What the regression test establishes

The new test builds the chained overlap directly.

Before transformation, it contains:

2 MatMulInteger nodes
2 Cast nodes
3 Mul nodes

After transformation, it expects:

1 MatMulIntegerToFloat
1 remaining MatMulInteger
1 remaining Cast
1 remaining Mul

Those counts establish both sides of the intended decision.

The first candidate must still fuse.

The second candidate must remain intact rather than becoming a corrupted second fusion.

The graph transformer framework also resolves the graph after a modification. A transformation that leaves invalid producer-consumer relationships therefore cannot pass solely because the operator counts look plausible.

What the test does not establish

The added test uses the structural transformer helper rather than a baseline-versus-optimized inference comparison.

It does not execute both graphs and compare their numerical outputs.

It also does not build the optional bias form:

scaled result
        ↓
       Add
        ↓
      output

Nor does it explicitly exercise provider-specific logic such as the DML large-tensor branch.

These are limits on the regression matrix, not evidence of a defect in the merged change.

The test reproduces the overlap that motivated the patch, confirms that only the independent candidate is fused, and requires the resulting graph to resolve successfully.

The ownership rule behind the fix

A graph rewrite does more than recognize shapes.

Once it accepts a match, it claims the participating nodes and promises to replace their combined behavior with a new node.

Physical deletion can occur later. Exclusive ownership cannot.

The restored sequence is:

find local candidate
        ↓
check that none of its nodes are already claimed
        ↓
create replacement
        ↓
reserve every replaced node immediately
        ↓
continue scanning
        ↓
remove reserved nodes by identity

A deferred graph rewrite must reserve matched nodes when the rewrite is accepted, not when deletion eventually occurs.

That is the overlap boundary repaired by ONNX Runtime PR #32038.


Sources

https://github.com/microsoft/onnxruntime/pull/32038

https://github.com/microsoft/onnxruntime/commit/0e3af8f08c8f12a2d35d2070cfdf11e424b9f959

https://github.com/microsoft/onnxruntime/blob/b1f76d586aaf545ccb92198934fa49553a806a1c/onnxruntime/core/optimizer/matmul_integer_to_float.cc

https://github.com/microsoft/onnxruntime/blob/0e3af8f08c8f12a2d35d2070cfdf11e424b9f959/onnxruntime/core/optimizer/matmul_integer_to_float.cc

https://github.com/microsoft/onnxruntime/blob/0e3af8f08c8f12a2d35d2070cfdf11e424b9f959/onnxruntime/test/optimizer/graph_transform_test.cc

https://github.com/microsoft/onnxruntime/blob/0e3af8f08c8f12a2d35d2070cfdf11e424b9f959/onnxruntime/test/unittest_util/graph_transform_test_builder.cc

https://github.com/microsoft/onnxruntime/blob/0e3af8f08c8f12a2d35d2070cfdf11e424b9f959/onnxru

Popular posts from this blog

AMD Is Trying to Turn Kernel Diversity Into One Software Asset

PyTorch #188031 — Why Did Forward Use 64-Bit Indexing While Backward Still Used uint32_t?

NVIDIA CUTLASS #3017 — Why load() and store() Mapped the Same Tile Coordinate to Different Addresses