Skip to content

Commit 90ce648

Browse files
committed
ggml-openvino: keep stateful execution with -fa off
Keep the V cache token-major in stateful mode instead of falling back to stateless execution. SET_ROWS appends to the state, and PERMUTE reads it back in the layout attention needs, so MUL_MAT does not transpose it. Dropping a reshape in grouped attention lets OpenVINO fuse it into SDPA. For padded V caches, slice after the transpose so the cache is read in place, without a copy that is recompiled for every KV length. This removes the stateless fallback for -fa off.
1 parent 0974aef commit 90ce648

8 files changed

Lines changed: 73 additions & 17 deletions

File tree

‎docs/backend/OPENVINO.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,6 @@ build\ReleaseOV\bin\llama-simple.exe -m "C:\models\Llama-3.2-1B-Instruct-Q4_K_M.
801801
802802
- `llama-bench`: requires `-fa 1` (flash-attention).
803803
- `llama-cli --context-shift`: stateless only (`GGML_OPENVINO_STATEFUL_EXECUTION=0`). In stateful mode the KV cache is owned by the OpenVINO model and cannot be shifted externally.
804-
- `-fa off`: stateless only. With `-fa off` the V cache is stored transposed, which the stateful KV cache does not support; with `GGML_OPENVINO_STATEFUL_EXECUTION=1` the context falls back to stateless execution and logs a warning.
805804
- `llama-server`: only one chat session/thread when `GGML_OPENVINO_STATEFUL_EXECUTION=1`.
806805
807806
**GPU-specific**

‎ggml/src/ggml-openvino/ggml-decoder.cpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,14 @@ int GgmlOvDecoder::compute_op_case(const ggml_tensor * node) const {
452452
}
453453
break;
454454
}
455+
case GGML_OP_SET_ROWS: {
456+
// `-fa off` writes the V cache transposed, through a [1, n] reshape of the cache: one element per row
457+
if (node->src[2] != nullptr && node->src[2]->op == GGML_OP_RESHAPE && node->src[2]->ne[0] == 1 &&
458+
is_kvcache(node->src[2]->src[0], nullptr)) {
459+
op_case = 1;
460+
}
461+
break;
462+
}
455463
case GGML_OP_MUL_MAT: {
456464
if (node->src[1]->op == GGML_OP_SOFT_MAX) {
457465
// In the case of `-fa off`, softmax is used, v_trans=true, the dynamic dim is ne[0] for cache_v

‎ggml/src/ggml-openvino/openvino/op/mulmat.cpp‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ OutputVector translate_mulmat(const NodeContext & context) {
3131

3232
ov::Output<ov::Node> B = process_view_input_new(context, 0);
3333
ov::Output<ov::Node> A = process_view_input_new(context, 1);
34+
// the stateful V cache with `-fa off` is not transposed (see permute)
35+
const bool transpose_b = B.get_node()->get_rt_info().count(GGML_OV_V_TOKEN_MAJOR) == 0;
3436

3537
if (A.get_element_type() != B.get_element_type()) {
3638
B = std::make_shared<ov::op::v0::Convert>(B, context.get_input_type(1));
@@ -55,9 +57,19 @@ OutputVector translate_mulmat(const NodeContext & context) {
5557
// A [n, batch_large, rows, k] -> [n, batch_small, factor * rows, k] (a pure reshape: the group is adjacent)
5658
// A x B^T [n, batch_small, factor * rows, cols] -> [n, batch_large, rows, cols]
5759
// The zeros copy the input dimensions (special_zero), which may be dynamic.
58-
auto A_grouped = std::make_shared<ov::op::v1::Reshape>(
60+
ov::Output<Node> A_grouped = std::make_shared<ov::op::v1::Reshape>(
5961
A, ov::op::v0::Constant::create(ov::element::i64, {4}, std::vector<int64_t>{0, batch_small, -1, 0}), true);
60-
ov::Output<Node> res = std::make_shared<ov::op::v0::MatMul>(A_grouped, B, false, true);
62+
// A is often a Reshape of the grouped scores (see softmax): take them directly. With one Reshape less, the
63+
// OpenVINO SDPAFusion can match the attention
64+
const auto * A_reshape = ov::as_type<ov::op::v1::Reshape>(A.get_node());
65+
const auto * A_target =
66+
A_reshape != nullptr ? ov::as_type<ov::op::v0::Constant>(A_reshape->get_input_node_ptr(1)) : nullptr;
67+
if (A_target != nullptr && A_reshape->get_special_zero() &&
68+
A_target->cast_vector<int64_t>() == std::vector<int64_t>{0, batch_large, -1, 0} &&
69+
A_reshape->get_input_partial_shape(0)[1] == batch_small) {
70+
A_grouped = A_reshape->input_value(0);
71+
}
72+
ov::Output<Node> res = std::make_shared<ov::op::v0::MatMul>(A_grouped, B, false, transpose_b);
6173
res = std::make_shared<ov::op::v1::Reshape>(
6274
res, ov::op::v0::Constant::create(ov::element::i64, {4}, std::vector<int64_t>{0, batch_large, -1, 0}),
6375
true);
@@ -96,7 +108,6 @@ OutputVector translate_mulmat(const NodeContext & context) {
96108
A = Z;
97109
}
98110

99-
bool transpose_b = true;
100111
ov::Output<Node> res = std::make_shared<ov::op::v0::MatMul>(A, B, false, transpose_b);
101112

102113
const auto output_type = context.get_output_type();

‎ggml/src/ggml-openvino/openvino/op/pad.cpp‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,14 @@ OutputVector translate_pad(const NodeContext & context) {
7676
return rename_outputs_with_suffix({res}, context.get_name());
7777
}
7878

79-
const std::vector<int64_t> pads_begin = {pads[6], pads[4], pads[2], pads[0]};
80-
const std::vector<int64_t> pads_end = {pads[7], pads[5], pads[3], pads[1]};
79+
std::vector<int64_t> pads_begin = {pads[6], pads[4], pads[2], pads[0]};
80+
std::vector<int64_t> pads_end = {pads[7], pads[5], pads[3], pads[1]};
81+
// stateful execution drops the leading size-1 batch dim
82+
const auto input_rank = input.get_partial_shape().rank();
83+
if (input_rank.is_static() && input_rank.get_length() == 3 && pads_begin[0] == 0 && pads_end[0] == 0) {
84+
pads_begin.erase(pads_begin.begin());
85+
pads_end.erase(pads_end.begin());
86+
}
8187

8288
auto pads_begin_node = ov::op::v0::Constant::create(ov::element::i64, {pads_begin.size()}, pads_begin);
8389
auto pads_end_node = ov::op::v0::Constant::create(ov::element::i64, {pads_end.size()}, pads_end);

‎ggml/src/ggml-openvino/openvino/op/permute.cpp‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,40 @@ OutputVector translate_permute(const NodeContext & context) {
4646
static_cast<int64_t>(perm_values.size() - 1 - input_axis);
4747
}
4848
}
49-
if (op_case == 1 || context.is_stateful()) {
49+
if (context.is_stateful() && (op_case == 5 || op_case == 6)) {
50+
// `-fa off` reads V transposed. The stateful V cache is token-major, [1, seq, n_heads_kv, head_size]
51+
const auto output_shape = context.get_output_shape().to_shape();
52+
const int64_t n_heads = output_shape[1];
53+
const int64_t head_size = output_shape[2];
54+
const auto & state_shape = src.get_partial_shape();
55+
const bool single_head = n_heads == 1 && state_shape[2].get_length() == 1;
56+
if (!single_head && state_shape[2].get_length() * state_shape[3].get_length() != n_heads * head_size) {
57+
// llama.cpp pads the V cache rows to the widest layer: keep this layer's rows
58+
auto zero = ov::op::v0::Constant::create(ov::element::i64, {1}, {0});
59+
auto one = ov::op::v0::Constant::create(ov::element::i64, {1}, {1});
60+
auto rows = std::make_shared<ov::op::v1::Reshape>(
61+
src, ov::op::v0::Constant::create(ov::element::i64, {3}, std::vector<int64_t>{0, 0, -1}), true);
62+
auto sliced = std::make_shared<ov::op::v8::Slice>(
63+
rows, zero, ov::op::v0::Constant::create(ov::element::i64, {1}, {n_heads * head_size}), one,
64+
ov::op::v0::Constant::create(ov::element::i64, {1}, {2}));
65+
src = std::make_shared<ov::op::v1::Reshape>(
66+
sliced,
67+
ov::op::v0::Constant::create(ov::element::i64, {4}, std::vector<int64_t>{0, 0, n_heads, head_size}),
68+
true);
69+
}
70+
// keep V as [1, n_heads_kv, kv, head_size]: MUL_MAT then multiplies without transposing it
71+
res = std::make_shared<ov::op::v1::Transpose>(
72+
src, ov::op::v0::Constant::create(ov::element::i64, {4}, std::vector<int64_t>{0, 2, 1, 3}));
73+
if (single_head && state_shape[3].get_length() != head_size) {
74+
// the padded rows, as above. After the Transpose, which then cancels the state relayout, so the Slice reads
75+
// the KV cache in place: between the two they become copies of the cache, recompiled for every length
76+
res = std::make_shared<ov::op::v8::Slice>(res, ov::op::v0::Constant::create(ov::element::i64, {1}, {0}),
77+
ov::op::v0::Constant::create(ov::element::i64, {1}, {head_size}),
78+
ov::op::v0::Constant::create(ov::element::i64, {1}, {1}),
79+
ov::op::v0::Constant::create(ov::element::i64, {1}, {3}));
80+
}
81+
res.get_node()->get_rt_info()[GGML_OV_V_TOKEN_MAJOR] = true;
82+
} else if (op_case == 1 || context.is_stateful()) {
5083
// The stateful path carries hidden-state tensors in a rank-3 layout (the
5184
// leading batch dim is dropped, e.g. Gemma4's per-layer-embedding path). The
5285
// perm above is rank-4; when the actual input is rank-3, drop the batch axis

‎ggml/src/ggml-openvino/openvino/op/set_rows.cpp‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,13 @@ OutputVector translate_set_rows(const NodeContext & context) {
5555
Output<Node> res;
5656
if (context.is_stateful()) {
5757
int concat_axis = 1;
58-
// the stateful cache appends whole tokens; with `-fa off` llama.cpp writes the V cache transposed,
59-
// one element per row
58+
if (context.get_op_case() == 1) {
59+
// `-fa off` writes V transposed through a [1, n] reshape of the cache. The state keeps V token-major
60+
// like K, and the new values are already in token order, so append them to the state itself
61+
dst = dst.get_node()->input_value(0);
62+
}
6063
FRONT_END_OP_CONVERSION_CHECK(dst.get_partial_shape()[2].is_static() && dst.get_partial_shape()[3].is_static(),
61-
"Stateful execution needs flash attention (-fa on): with -fa off the V cache "
62-
"is written transposed");
64+
"Unsupported KV cache layout for stateful execution");
6365
int64_t dim2 = dst.get_partial_shape()[2].get_length();
6466
int64_t dim3 = dst.get_partial_shape()[3].get_length();
6567
data = std::make_shared<ov::op::v1::Reshape>(

‎ggml/src/ggml-openvino/openvino/utils.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ inline bool is_kvcache_4d_shape(const ov::PartialShape & shape) {
8686
// stacked per B matrix
8787
constexpr const char * GGML_OV_GROUPED_ROWS = "ggml_ov_grouped_rows";
8888

89+
// rt_info key on the stateful V cache read with `-fa off` (permute): V is [.., kv, head_size], not transposed
90+
constexpr const char * GGML_OV_V_TOKEN_MAJOR = "ggml_ov_v_token_major";
91+
8992
namespace op {
9093
template <typename T> OutputVector translate_1to1_match_2_inputs(const NodeContext & context) {
9194
num_inputs_check(context, 2, 2);

‎ggml/src/ggml-openvino/utils.cpp‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,12 +1066,6 @@ enum ggml_status ov_graph_compute_dynamic(ggml_cgraph * cgraph, const std::share
10661066
const int64_t graph_param_compute_time = ggml_time_us() - graph_param_compute_start_time;
10671067

10681068
const bool cache_enabled = !model_is_splitted && !cache_disabled;
1069-
if (r_ctx->stateful && m_params.v_trans) {
1070-
GGML_LOG_WARN(
1071-
"ggml-openvino: disabling stateful execution for this context: the graph writes a transposed V cache "
1072-
"(flash attention is off), which the stateful KV cache does not support\n");
1073-
r_ctx->stateful = false;
1074-
}
10751069
const bool stateful_recurrent = r_ctx->stateful && m_params.state_size >= 0;
10761070
const bool stateful_kv_only = r_ctx->stateful && !stateful_recurrent;
10771071
if (r_ctx->stateful &&

0 commit comments

Comments
 (0)