Skip to content

Commit defb8fc

Browse files
committed
Restrict fuse_attention to rank-4 SDPA graphs
Only fuse graphs that the Attention op can represent: - Q, K and V must be rank 4 with static, matching batch and head dims. - The default scale must come from a Shape covering all of Q and a Slice that selects only the last dimension (start -1, end >= 4, axis 0/-1, step 1). Previously a Shape end or an unchecked Slice end/step could pick a different dimension and silently change the scale. - An explicit scale must be a scalar, so a higher-rank constant cannot promote Q or K^T and change the output rank. Add negative tests for the shape-selection, scale-rank and batch mismatch cases. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VPahbkPmWYWChRCBQX7osk Signed-off-by: take-cheeze <takechi101010@gmail.com>
1 parent 7550317 commit defb8fc

2 files changed

Lines changed: 93 additions & 4 deletions

File tree

‎onnxoptimizer/passes/fuse_attention.h‎

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,56 @@ struct FuseAttention final : public PredicateBasedPass {
107107
if (!CheckKind(dim, kSlice)) {
108108
return false;
109109
}
110-
// The slice must select the last dimension of the shape.
110+
// `q` is rank 4 (checked by the caller), so the slice must select exactly
111+
// the last dimension: starts=[-1], ends >= 4, optional axes in {0, -1} and
112+
// optional steps=1. Anything else could select more than one dimension.
113+
const size_t num_inputs = dim->inputs().size();
111114
int64_t start;
112-
if (dim->inputs().size() < 2 ||
113-
!FetchSoleIntValueOfTensor(dim->input(1), start) || start != -1) {
115+
int64_t end;
116+
if (num_inputs < 3 || num_inputs > 5 ||
117+
!FetchSoleIntValueOfTensor(dim->input(1), start) || start != -1 ||
118+
!FetchSoleIntValueOfTensor(dim->input(2), end) || end < 4) {
114119
return false;
115120
}
121+
if (num_inputs >= 4) {
122+
int64_t axis;
123+
if (!FetchSoleIntValueOfTensor(dim->input(3), axis) ||
124+
(axis != 0 && axis != -1)) {
125+
return false;
126+
}
127+
}
128+
if (num_inputs == 5) {
129+
int64_t step;
130+
if (!FetchSoleIntValueOfTensor(dim->input(4), step) || step != 1) {
131+
return false;
132+
}
133+
}
116134
const Node* shape = dim->input(0)->node();
117-
return CheckKind(shape, "Shape") && shape->input(0) == q;
135+
if (!CheckKind(shape, "Shape") || shape->input(0) != q) {
136+
return false;
137+
}
138+
// The Shape must cover all of `q`; a start/end selection would shift which
139+
// dimension the slice reads.
140+
const auto range = FetchStartAndEndAttrOfShape(shape, 4);
141+
return range.first == 0 && range.second == 4;
142+
}
143+
144+
// Returns true when `v` is a rank-4 `[batch, heads, seq, head_size]` tensor
145+
// whose batch and head dimensions are statically known.
146+
static bool IsRank4AttentionLayout(const Value* v) {
147+
if (!v->has_sizes() || v->sizes().size() != 4) {
148+
return false;
149+
}
150+
const auto& dims = v->sizes();
151+
return dims[0].is_int && dims[1].is_int;
152+
}
153+
154+
// Returns true when `a` and `b` agree on their (static) batch and head
155+
// dimensions. Attention requires matching batch and head counts for Q, K, V.
156+
static bool HaveSameBatchAndHeads(const Value* a, const Value* b) {
157+
const auto& da = a->sizes();
158+
const auto& db = b->sizes();
159+
return da[0].dim == db[0].dim && da[1].dim == db[1].dim;
118160
}
119161

120162
bool runTransform(Node* n, Graph& graph,
@@ -215,6 +257,14 @@ struct FuseAttention final : public PredicateBasedPass {
215257
return false;
216258
}
217259

260+
// Attention only represents rank-4 [batch, heads, seq, head_size] inputs
261+
// with matching batch and head counts. Leave every other graph unchanged.
262+
if (!IsRank4AttentionLayout(query) || !IsRank4AttentionLayout(key) ||
263+
!IsRank4AttentionLayout(value) || !HaveSameBatchAndHeads(query, key) ||
264+
!HaveSameBatchAndHeads(query, value)) {
265+
return false;
266+
}
267+
218268
// Resolve the effective scale applied to Q @ K^T.
219269
bool has_scale = false;
220270
float scale = 0.0f;
@@ -223,6 +273,14 @@ struct FuseAttention final : public PredicateBasedPass {
223273
if (FetchSoleValueOfTensor(scale_q, scale_q_val) &&
224274
FetchSoleValueOfTensor(scale_k, scale_k_val)) {
225275
// Explicit scale: torch emits sqrt(scale) constants on both branches.
276+
// Only scalars are accepted: a higher-rank scale would broadcast Q or K^T
277+
// to a larger rank than the fused Attention output.
278+
auto is_scalar = [](const Value* v) {
279+
return v->has_sizes() && v->sizes().empty();
280+
};
281+
if (!is_scalar(scale_q) || !is_scalar(scale_k)) {
282+
return false;
283+
}
226284
has_scale = true;
227285
scale = scale_q_val * scale_k_val;
228286
} else if (CheckKind(scale_q, kSqrt) && CheckKind(scale_k, kSqrt) &&

‎onnxoptimizer/test/optimizer_test.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5105,6 +5105,37 @@ def vi(name, shape):
51055105
optimized_model = self._optimized(model, ["fuse_attention"])
51065106
assert all(n.op_type != "Attention" for n in optimized_model.graph.node)
51075107

5108+
def test_fuse_attention_shape_end_selection_no_fuse(self): # type: () -> None
5109+
# Shape(q, end=3) followed by Slice(-1) reads dim 2, not head_size, so
5110+
# the default scale must not be assumed.
5111+
model = self._make_sdpa_model(mask="none", scale=None)
5112+
shape_node = [n for n in model.graph.node if n.op_type == "Shape"][0]
5113+
shape_node.attribute.append(helper.make_attribute("end", 3))
5114+
optimized_model = self._optimized(model, ["fuse_attention"])
5115+
assert all(n.op_type != "Attention" for n in optimized_model.graph.node)
5116+
5117+
def test_fuse_attention_leading_dim_scale_no_fuse(self): # type: () -> None
5118+
# A [1,1,1,1,1] scale would promote Q/K^T to rank 5; the fused Attention
5119+
# output would then have the wrong rank.
5120+
model = self._make_sdpa_model(mask="none", scale=0.125)
5121+
sf = float(np.sqrt(0.125))
5122+
for i, init in enumerate(model.graph.initializer):
5123+
if init.name in ("sqf_q", "sqf_k"):
5124+
model.graph.initializer[i].CopyFrom(
5125+
numpy_helper.from_array(
5126+
np.full((1, 1, 1, 1, 1), sf, np.float32), init.name
5127+
)
5128+
)
5129+
optimized_model = self._optimized(model, ["fuse_attention"])
5130+
assert all(n.op_type != "Attention" for n in optimized_model.graph.node)
5131+
5132+
def test_fuse_attention_batch_mismatch_no_fuse(self): # type: () -> None
5133+
# Q has batch 1 while K/V have batch 2; Attention needs matching batches.
5134+
model = self._make_sdpa_model(mask="none", scale=None)
5135+
model.graph.input[0].type.tensor_type.shape.dim[0].dim_value = 1
5136+
optimized_model = self._optimized(model, ["fuse_attention"])
5137+
assert all(n.op_type != "Attention" for n in optimized_model.graph.node)
5138+
51085139
@unittest.skipUnless(has_torch, "onnx test needs torch")
51095140
def test_fuse_attention_torch_sdpa_export(self): # type: () -> None
51105141
# Fuse graphs produced by the real torch.onnx exporter.

0 commit comments

Comments
 (0)