Skip to content

Commit af1fab4

Browse files
committed
[Transformations] Fuse split-attention sub-graph into v13::ScaledDotProductAttention
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.
1 parent 44f104f commit af1fab4

3 files changed

Lines changed: 423 additions & 0 deletions

File tree

src/common/transformations/include/transformations/common_optimizations/sdpa_fusion.hpp

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,83 @@ class TRANSFORMATIONS_API SDPAFusionMatcherSinks : public ov::pass::MatcherPass
121121
SDPAFusionMatcherSinks();
122122
};
123123

124+
/// This pass fuses a "split-attention" sub-graph into a single ScaledDotProductAttention.
125+
/// The pattern is produced by some Gemma models which compute attention against past KV
126+
/// and current KV separately and merge via Concat/VariadicSplit.
127+
///
128+
/// Before:
129+
/// ┌─────────┐ ┌─────┐ ┌───────┐
130+
/// │ K_cache │ │ Q │ │ K_new │
131+
/// └────┬────┘ └──┬──┘ └───┬───┘
132+
/// │ ┌──┴──┐ │
133+
/// │ ┌──┘ └──┐ │
134+
/// │ │ │ │
135+
/// ┌────┴───────┴─┐ ┌────┴──────┴┐
136+
/// │ MatMul │ │ MatMul │
137+
/// └──────┬───────┘ └──────┬─────┘
138+
/// │ qk_cache │ qk_new
139+
/// └──────────┐ ┌────┘
140+
/// │ │
141+
/// ┌───┴───┴───┐
142+
/// │ Concat │ (axis=-1)
143+
/// └─────┬─────┘
144+
/// │ ┌─────────────┐
145+
/// ┌──┴──┐ │AttentionMask│
146+
/// │ Add │<────────┤ │
147+
/// └──┬──┘ └─────────────┘
148+
/// │
149+
/// ┌────┴────┐
150+
/// │ Softmax │ (axis=-1)
151+
/// └────┬────┘
152+
/// │
153+
/// ┌────────┴────────┐
154+
/// │ VariadicSplit │ (axis=-1)
155+
/// └───┬─────────┬───┘
156+
/// pc │ │ pn
157+
/// ┌─────────┐ │ │ ┌───────┐
158+
/// │ V_cache │ │ │ │ V_new │
159+
/// └────┬────┘ │ │ └───┬───┘
160+
/// │ ┌──┘ └──┐ │
161+
/// │ │ │ │
162+
/// ┌────┴──────┴┐ ┌───┴─────┴──┐
163+
/// │ MatMul │ │ MatMul │
164+
/// └──────┬─────┘ └──────┬─────┘
165+
/// │ attn_c │ attn_n
166+
/// └──────────┐ ┌────────┘
167+
/// │ │
168+
/// ┌─┴───┴─┐
169+
/// │ Add │
170+
/// └───┬───┘
171+
/// │
172+
/// ┌───┴───┐
173+
/// │ Output│
174+
/// └───────┘
175+
///
176+
/// After:
177+
/// ┌───────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────┐
178+
/// │ Q │ │Concat(K_cache,K_new) │ │Concat(V_cache,V_new) │ │AttentionMask│
179+
/// └───┬───┘ └──────────┬───────────┘ └──────────┬───────────┘ └──────┬──────┘
180+
/// │ │ │ │
181+
/// ┌───┴─────────────────┴─────────────────────────┴─────────────────────┴─┐
182+
/// │ ScaledDotProductAttention │
183+
/// └─────────────────────────────────┬─────────────────────────────────────┘
184+
/// │
185+
/// ┌────┴────┐
186+
/// │ Output │
187+
/// └─────────┘
188+
///
189+
/// K_cache and K_new are concatenated along the sequence axis (and similarly for V).
190+
/// When the matmul transpose flags indicate that K or V are stored with seq as the
191+
/// innermost dimension (i.e. [B,H,D,S]), explicit Transpose nodes are inserted to bring
192+
/// them to the canonical [B,H,S,D] layout required by v13::ScaledDotProductAttention.
193+
/// Scale is set to 1.0 because the matched pattern does not contain a separate scale node;
194+
/// any required scaling is assumed to be pre-applied to Q by the model.
195+
class TRANSFORMATIONS_API SDPASplitAttentionFusionMatcher : public ov::pass::MatcherPass {
196+
public:
197+
OPENVINO_MATCHER_PASS_RTTI("SDPASplitAttentionFusionMatcher", "0");
198+
SDPASplitAttentionFusionMatcher();
199+
};
200+
124201
// Temporary wrapper to enable Symbolic infrastructure inside.
125202
class TRANSFORMATIONS_API SDPAFusion : public ov::pass::ModelPass {
126203
public:

src/common/transformations/src/transformations/common_optimizations/sdpa_fusion.cpp

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
#include "openvino/op/subtract.hpp"
2727
#include "openvino/op/transpose.hpp"
2828
#include "openvino/op/unsqueeze.hpp"
29+
#include "openvino/op/variadic_split.hpp"
2930
#include "openvino/pass/pattern/op/optional.hpp"
3031
#include "openvino/pass/pattern/op/pattern.hpp"
3132
#include "openvino/pass/pattern/op/wrap_type.hpp"
@@ -152,6 +153,7 @@ bool SDPAFusion::run_on_model(const std::shared_ptr<ov::Model>& model) {
152153
ov::pass::SymbolicOptimizations symbolic_optimizations(false, get_pass_config());
153154
auto symbolic_ctx_manager = symbolic_optimizations.get_manager();
154155
symbolic_ctx_manager->register_pass<ov::pass::SDPAFusionMatcher>();
156+
symbolic_ctx_manager->register_pass<ov::pass::SDPASplitAttentionFusionMatcher>();
155157
symbolic_ctx_manager->register_pass<ov::pass::SDPAFusionMatcherSinks>();
156158
symbolic_ctx_manager->register_pass<ov::pass::SDPAReshapeFusion>();
157159
return symbolic_optimizations.run_on_model(model);
@@ -632,4 +634,196 @@ SDPAFusionMatcherSinks::SDPAFusionMatcherSinks() {
632634
this->register_matcher(m, callback);
633635
}
634636

637+
SDPASplitAttentionFusionMatcher::SDPASplitAttentionFusionMatcher() {
638+
MATCHER_SCOPE(SDPASplitAttentionFusionMatcher);
639+
640+
// Match the "split-attention" pattern; see header documentation.
641+
auto q_input = any_input();
642+
auto k_cache_input = any_input();
643+
auto k_new_input = any_input();
644+
auto v_cache_input = any_input();
645+
auto v_new_input = any_input();
646+
auto mask_input = any_input();
647+
648+
auto qk_cache = wrap_type<v0::MatMul>({q_input, k_cache_input}, consumers_count(1));
649+
auto qk_new = wrap_type<v0::MatMul>({q_input, k_new_input}, consumers_count(1));
650+
651+
auto scores_concat = wrap_type<v0::Concat>({qk_cache, qk_new}, consumers_count(1));
652+
auto masked_scores = wrap_type<v1::Add>({scores_concat, mask_input}, consumers_count(1));
653+
auto softmax = wrap_type<v8::Softmax>({masked_scores}, consumers_count(1));
654+
655+
auto split_axis = any_input();
656+
auto split_sizes = any_input();
657+
auto vsplit = wrap_type<v1::VariadicSplit>({softmax, split_axis, split_sizes});
658+
659+
// VariadicSplit has multiple outputs; the v0::MatMul wrap cannot easily express
660+
// "use output 0/1 of vsplit", so the first MatMul input is matched as any_input and
661+
// verified explicitly in the callback below.
662+
auto attn_cache = wrap_type<v0::MatMul>({any_input(), v_cache_input});
663+
auto attn_new = wrap_type<v0::MatMul>({any_input(), v_new_input});
664+
auto output_add = wrap_type<v1::Add>({attn_cache, attn_new});
665+
666+
ov::matcher_pass_callback callback = [=](Matcher& m) {
667+
if (transformation_callback(m.get_match_root())) {
668+
return false;
669+
}
670+
671+
auto add_node = ov::as_type_ptr<v1::Add>(m.get_match_root());
672+
if (!add_node) {
673+
return false;
674+
}
675+
676+
auto matmul_cache = ov::as_type_ptr<v0::MatMul>(add_node->input_value(0).get_node_shared_ptr());
677+
auto matmul_new = ov::as_type_ptr<v0::MatMul>(add_node->input_value(1).get_node_shared_ptr());
678+
if (!matmul_cache || !matmul_new) {
679+
return false;
680+
}
681+
682+
// Both V matmuls' first input must come from the same VariadicSplit (output 0 and 1).
683+
auto cache_src = matmul_cache->input_value(0);
684+
auto new_src = matmul_new->input_value(0);
685+
auto vsplit_node = ov::as_type_ptr<v1::VariadicSplit>(cache_src.get_node_shared_ptr());
686+
if (!vsplit_node ||
687+
vsplit_node != ov::as_type_ptr<v1::VariadicSplit>(new_src.get_node_shared_ptr())) {
688+
return false;
689+
}
690+
if (cache_src.get_index() != 0 || new_src.get_index() != 1) {
691+
return false;
692+
}
693+
694+
auto softmax_node = ov::as_type_ptr<v8::Softmax>(vsplit_node->input_value(0).get_node_shared_ptr());
695+
if (!softmax_node) {
696+
return false;
697+
}
698+
699+
// Softmax must be on the last axis.
700+
auto sm_rank = softmax_node->get_output_partial_shape(0).rank();
701+
if (sm_rank.is_dynamic()) {
702+
return false;
703+
}
704+
auto sm_axis = softmax_node->get_axis();
705+
if (sm_axis < 0) {
706+
sm_axis += sm_rank.get_length();
707+
}
708+
if (sm_axis != sm_rank.get_length() - 1) {
709+
return false;
710+
}
711+
712+
auto mask_add_node = ov::as_type_ptr<v1::Add>(softmax_node->input_value(0).get_node_shared_ptr());
713+
if (!mask_add_node) {
714+
return false;
715+
}
716+
717+
auto concat_node = ov::as_type_ptr<v0::Concat>(mask_add_node->input_value(0).get_node_shared_ptr());
718+
if (!concat_node || concat_node->get_input_size() != 2) {
719+
return false;
720+
}
721+
auto concat_rank = concat_node->get_output_partial_shape(0).rank();
722+
if (concat_rank.is_dynamic()) {
723+
return false;
724+
}
725+
auto concat_axis = concat_node->get_axis();
726+
if (concat_axis < 0) {
727+
concat_axis += concat_rank.get_length();
728+
}
729+
if (concat_axis != concat_rank.get_length() - 1) {
730+
return false;
731+
}
732+
733+
auto qk_cache_node = ov::as_type_ptr<v0::MatMul>(concat_node->input_value(0).get_node_shared_ptr());
734+
auto qk_new_node = ov::as_type_ptr<v0::MatMul>(concat_node->input_value(1).get_node_shared_ptr());
735+
if (!qk_cache_node || !qk_new_node) {
736+
return false;
737+
}
738+
739+
// Both QK matmuls must share the same Q and agree on transpose flags.
740+
if (qk_cache_node->input_value(0) != qk_new_node->input_value(0)) {
741+
return false;
742+
}
743+
if (qk_cache_node->get_transpose_a() || qk_new_node->get_transpose_a()) {
744+
return false;
745+
}
746+
if (qk_cache_node->get_transpose_b() != qk_new_node->get_transpose_b()) {
747+
return false;
748+
}
749+
if (matmul_cache->get_transpose_a() || matmul_new->get_transpose_a()) {
750+
return false;
751+
}
752+
if (matmul_cache->get_transpose_b() != matmul_new->get_transpose_b()) {
753+
return false;
754+
}
755+
756+
auto Q = qk_cache_node->input_value(0);
757+
auto K_cache = qk_cache_node->input_value(1);
758+
auto K_new = qk_new_node->input_value(1);
759+
auto V_cache = matmul_cache->input_value(1);
760+
auto V_new = matmul_new->input_value(1);
761+
auto mask_value = mask_add_node->input_value(1);
762+
763+
// Require static 4D ranks for predictable layout reasoning.
764+
for (const auto& in : {Q, K_cache, K_new, V_cache, V_new}) {
765+
const auto& ps = in.get_partial_shape();
766+
if (ps.rank().is_dynamic() || ps.rank().get_length() != 4) {
767+
return false;
768+
}
769+
}
770+
771+
// Interpret MatMul transpose flags as a physical-layout hint:
772+
// qk transpose_b == true; K stored as [B,H,S,D] (standard); MatMul performs Q ┬╖ K^T.
773+
// qk transpose_b == false; K stored as [B,H,D,S] (already K^T in memory).
774+
// attn transpose_b == true; V stored as [B,H,D,S]; MatMul performs probs ┬╖ V^T.
775+
// attn transpose_b == false; V stored as [B,H,S,D] (standard).
776+
const bool k_is_transposed = !qk_cache_node->get_transpose_b();
777+
const bool v_is_transposed = matmul_cache->get_transpose_b();
778+
779+
// Concat along the physical sequence axis of each input, then add an explicit
780+
// Transpose to bring it to the [B,H,S,D] layout expected by v13::SDPA.
781+
const int64_t k_concat_axis = k_is_transposed ? 3 : 2;
782+
const int64_t v_concat_axis = v_is_transposed ? 3 : 2;
783+
784+
auto k_concat = std::make_shared<v0::Concat>(ov::OutputVector{K_cache, K_new}, k_concat_axis);
785+
auto v_concat = std::make_shared<v0::Concat>(ov::OutputVector{V_cache, V_new}, v_concat_axis);
786+
787+
std::shared_ptr<ov::Node> k_input = k_concat;
788+
std::shared_ptr<ov::Node> v_input = v_concat;
789+
790+
ov::NodeVector new_nodes{k_concat, v_concat};
791+
auto add_transpose = [&new_nodes](std::shared_ptr<ov::Node>& target) {
792+
auto perm = v0::Constant::create(ov::element::i64, ov::Shape{4}, {0, 1, 3, 2});
793+
auto transpose = std::make_shared<v1::Transpose>(target, perm);
794+
new_nodes.push_back(perm);
795+
new_nodes.push_back(transpose);
796+
target = transpose;
797+
};
798+
if (k_is_transposed) {
799+
add_transpose(k_input);
800+
}
801+
if (v_is_transposed) {
802+
add_transpose(v_input);
803+
}
804+
805+
// Scale = 1.0: the matched pattern has no explicit scale node; any scaling is
806+
// assumed to be baked into Q by the model (e.g. Gemma's query_pre_attn_scalar).
807+
auto scale_const = v0::Constant::create(Q.get_element_type(), ov::Shape{}, {1.0f});
808+
new_nodes.push_back(scale_const);
809+
810+
auto sdpa = std::make_shared<v13::ScaledDotProductAttention>(Q,
811+
k_input,
812+
v_input,
813+
mask_value,
814+
scale_const,
815+
/*is_causal=*/false);
816+
new_nodes.push_back(sdpa);
817+
818+
sdpa->set_friendly_name(add_node->get_friendly_name());
819+
ov::copy_runtime_info(m.get_matched_nodes(), new_nodes);
820+
ov::replace_node(add_node, sdpa);
821+
822+
return true;
823+
};
824+
825+
auto m = std::make_shared<Matcher>(output_add, matcher_name);
826+
this->register_matcher(m, callback);
827+
}
828+
635829
} // namespace ov::pass

0 commit comments

Comments
 (0)