Skip to content

[Transformations] Fuse split-attention sub-graph into v13::ScaledDotProductAttention#36153

Open
zlma7001 wants to merge 1 commit into
openvinotoolkit:masterfrom
zlma7001:sdpa
Open

[Transformations] Fuse split-attention sub-graph into v13::ScaledDotProductAttention#36153
zlma7001 wants to merge 1 commit into
openvinotoolkit:masterfrom
zlma7001:sdpa

Conversation

@zlma7001

@zlma7001 zlma7001 commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Some Gemma models compute attention against the persistent KV cache and the current step's KV separately, merging the results via Concat / VariadicSplit:

qk_cache = MatMul(Q, K_cache)
qk_new   = MatMul(Q, K_new)
scores   = Concat(qk_cache, qk_new, axis=-1)
masked   = Add(scores, mask)
probs    = Softmax(masked, axis=-1)
[pc, pn] = VariadicSplit(probs, axis=-1, sizes=[S_cache, S_new])
attn_c   = MatMul(pc, V_cache)
attn_n   = MatMul(pn, V_new)
output   = Add(attn_c, attn_n)

The existing SDPAFusionMatcher does not handle this shape, so the layer runs as ~12 unfused primitives and never reaches the plugins' optimized SDPA kernel.

Add SDPASplitAttentionFusionMatcher (registered from SDPAFusion::run_on_model) which concatenates K_cache+K_new and V_cache+V_new along the sequence axis implied by each MatMul's transpose_b, inserts a Transpose(0,1,3,2) when K or V is stored as [B,H,D,S] to restore the [B,H,S,D] layout required by v13::SDPA, and uses scale=1.0 (any scaling is assumed pre-baked into Q, e.g. Gemma's query_pre_attn_scalar). Restricted to static 4-D ranks; other shapes fall through unchanged.

Tests cover the standard [B,H,S,D] layout, the pre-transposed K and V variants, and a negative case where Softmax is on a non-trailing axis.

Tickets:

@zlma7001 zlma7001 requested a review from a team as a code owner June 1, 2026 02:26
@github-actions github-actions Bot added the category: transformations OpenVINO Runtime library - Transformations label Jun 1, 2026
@sys-openvino-ci sys-openvino-ci added the ExternalIntelPR External contributor from Intel label Jun 1, 2026
@CuriousPanCake

Copy link
Copy Markdown
Contributor

build_jenkins

@CuriousPanCake

Copy link
Copy Markdown
Contributor

build_jenkins

@zlma7001

Copy link
Copy Markdown
Contributor Author

Patch updated for code style requirement, PTAL

@CuriousPanCake

Copy link
Copy Markdown
Contributor

Just thinking out loud, wouldn't it be simpler to transform the graph in such a way that it's going to be matched by SDPAFusion? Such that in your transformation you won't fuse SDPA, but rather canonicalize the graph around it. I believe, that might make the code much simpler. What do you think?

@CuriousPanCake

Copy link
Copy Markdown
Contributor

build_jenkins

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR extends the common SDPA fusion transformations to recognize and fuse a “split-attention” attention pattern (separate cache/new KV attention merged via ConcatSoftmaxVariadicSplit → 2×MatMulAdd) into a single ov::op::v13::ScaledDotProductAttention, enabling optimized plugin SDPA kernels for affected Gemma-style graphs.

Changes:

  • Register a new SDPASplitAttentionFusionMatcher from SDPAFusion::run_on_model.
  • Implement the matcher to concatenate K_cache/K_new and V_cache/V_new along the implied sequence axis and insert (0,1,3,2) transposes when inputs are stored as [B,H,D,S].
  • Add unit tests covering standard layout, pre-transposed K, pre-transposed V, and a negative case (Softmax axis not trailing).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/common/transformations/src/transformations/common_optimizations/sdpa_fusion.cpp Adds and registers SDPASplitAttentionFusionMatcher that fuses the split-attention subgraph into v13::ScaledDotProductAttention.
src/common/transformations/include/transformations/common_optimizations/sdpa_fusion.hpp Declares the new matcher pass and documents the fused pattern.
src/common/transformations/tests/common_optimizations/sdpa_fusion_test.cpp Adds regression tests validating fusion behavior for split-attention patterns and a no-fusion negative case.

@zlma7001

Copy link
Copy Markdown
Contributor Author

Just thinking out loud, wouldn't it be simpler to transform the graph in such a way that it's going to be matched by SDPAFusion? Such that in your transformation you won't fuse SDPA, but rather canonicalize the graph around it. I believe, that might make the code much simpler. What do you think?

I considered that option. Conceptually it is attractive, but for this split-attention pattern I do not think it would make the implementation simpler. The graph is not just a syntactic variant of the existing SDPAFusion pattern: it computes QK and PV separately for cache/new branches, then uses Concat/Softmax/VariadicSplit/Add to preserve the cache/new partitioning. A canonicalization pass would still need to prove the same constraints currently checked by this matcher: matching concat/split axes and order, compatible split lengths, equivalent mask placement, and equivalent scale handling. It would also need to materialize a temporary canonical MatMul/Softmax/MatMul form and rely on a later pass to fuse it, which makes pass ordering and fallback behavior more delicate.
So I prefer kept this as a dedicated matcher that directly replaces the verified split-attention subgraph with SDPA.

@CuriousPanCake

Copy link
Copy Markdown
Contributor

build_jenkins

@zlma7001

Copy link
Copy Markdown
Contributor Author

Rebased to re-run CI, seems the previous failures were unrelated to this PR.

@CuriousPanCake

Copy link
Copy Markdown
Contributor

build_jenkins

1 similar comment
@mlyashko

Copy link
Copy Markdown

build_jenkins

@zlma7001

zlma7001 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased to retrigger ci, thanks!

@CuriousPanCake

Copy link
Copy Markdown
Contributor

build_jenkins

@CuriousPanCake

Copy link
Copy Markdown
Contributor

build_jenkins

@zlma7001

Copy link
Copy Markdown
Contributor Author

Thanks @CuriousPanCake for the review, PTAL

@CuriousPanCake

Copy link
Copy Markdown
Contributor

build_jenkins

…roductAttention

Some Gemma models compute attention against the persistent KV cache and
the current step's KV separately, merging the results via Concat /
VariadicSplit:

    qk_cache = MatMul(Q, K_cache)
    qk_new   = MatMul(Q, K_new)
    scores   = Concat(qk_cache, qk_new, axis=-1)
    masked   = Add(scores, mask)
    probs    = Softmax(masked, axis=-1)
    [pc, pn] = VariadicSplit(probs, axis=-1, sizes=[S_cache, S_new])
    attn_c   = MatMul(pc, V_cache)
    attn_n   = MatMul(pn, V_new)
    output   = Add(attn_c, attn_n)

The existing SDPAFusionMatcher does not handle this shape, so the layer
runs as ~12 unfused primitives and never reaches the plugins' optimized
SDPA kernel.

Add SDPASplitAttentionFusionMatcher (registered from SDPAFusion::run_on_model)
which concatenates K_cache+K_new and V_cache+V_new along the sequence
axis implied by each MatMul's transpose_b, inserts a Transpose(0,1,3,2)
when K or V is stored as [B,H,D,S] to restore the [B,H,S,D] layout
required by v13::SDPA, and uses scale=1.0 (any scaling is assumed
pre-baked into Q, e.g. Gemma's query_pre_attn_scalar). Restricted to
static 4-D ranks; other shapes fall through unchanged.

Tests cover the standard [B,H,S,D] layout, the pre-transposed K and V
variants, and a negative case where Softmax is on a non-trailing axis.
@zlma7001

Copy link
Copy Markdown
Contributor Author

Thanks Andrii! PTAL

SDPASplitAttentionFusionMatcher::SDPASplitAttentionFusionMatcher() {
MATCHER_SCOPE(SDPASplitAttentionFusionMatcher);

// Match the "split-attention" pattern; see header documentation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Match the "split-attention" pattern; see header documentation.

Comment on lines +672 to +675
auto add_node = pattern_map.at(output_add).get_node_shared_ptr();
auto matmul_cache = ov::as_type_ptr<v0::MatMul>(pattern_map.at(attn_cache).get_node_shared_ptr());
auto matmul_new = ov::as_type_ptr<v0::MatMul>(pattern_map.at(attn_new).get_node_shared_ptr());
auto vsplit_node = ov::as_type_ptr<v1::VariadicSplit>(pattern_map.at(vsplit_out0).get_node_shared_ptr());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since you're casting using ov::as_type_ptr(), check they're all not nullptr. Use one if statement for this.

Comment on lines +692 to +695
auto split_axis_value = axis_vec[0];
if (split_axis_value < 0) {
split_axis_value += split_rank.get_length();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Check for some normalization utils in OpenVINO. They should cover this functionality

return false;
}

auto softmax_node = ov::as_type_ptr<v8::Softmax>(pattern_map.at(softmax).get_node_shared_ptr());

@CuriousPanCake CuriousPanCake Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Check for nullptr.

Comment on lines +712 to +718
auto sm_axis = softmax_node->get_axis();
if (sm_axis < 0) {
sm_axis += sm_rank.get_length();
}
if (sm_axis != sm_rank.get_length() - 1) {
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same comment as above regarding normalization

if (concat_rank.is_dynamic()) {
return false;
}
const auto concat_axis = ov::util::try_normalize_axis(concat_node->get_axis(), concat_rank, *concat_node);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are you using try_normalize_axis instead of just normalize_axis? Not a request to change it, just asking to understand.

Comment on lines +763 to +764
std::shared_ptr<ov::Node> k_input = k_concat;
std::shared_ptr<ov::Node> v_input = v_concat;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we need these variables?

// Interpret MatMul transpose flags as a physical-layout hint:
// qk transpose_b == true; K stored as [B,H,S,D] (standard); MatMul performs Q ┬╖ K^T.
// qk transpose_b == false; K stored as [B,H,D,S] (already K^T in memory).
// attn transpose_b == true; V stored as [B,H,D,S]; MatMul performs probs ┬╖ V^T.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks like this comment hasn't been rendered correctly at the end of the line

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category: transformations OpenVINO Runtime library - Transformations ExternalIntelPR External contributor from Intel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants