Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/backend/OPENVINO.md
Original file line number Diff line number Diff line change
Expand Up @@ -715,10 +715,13 @@ Boolean flags follow a uniform convention: set to a **positive integer** (e.g. `
| `GGML_OPENVINO_STATEFUL_EXECUTION`| Boolean | `0` | Enable stateful KV cache for better performance. Recommended on CPU, GPU. |
| `GGML_OPENVINO_DISABLE_CACHE` | Boolean | `0` | Disable the in-process compiled-model / decoder cache (cache is on by default). Set to `1` to disable. |
| `GGML_OPENVINO_DISABLE_KV_SLICE` | Boolean | `0` | Disable the KV-cache input-tensor slicing optimization (slicing is on by default on CPU/GPU). Set to `1` to disable. |
| `GGML_OPENVINO_DISABLE_KV_STATE_RELAYOUT` | Boolean | `0` | Disable the stateful KV-state sequence-axis relayout (relayout is on by default). It moves the KV state sequence axis from dim 1 to dim 2, so the GPU plugin can append new tokens in place instead of copying the whole state every token, and the reader side no longer transposes the whole accumulated state. Set to `1` to disable. |
| `GGML_OPENVINO_MANUAL_GQA_ATTN` | Boolean | device-based | Tri-state. When **unset**, manual GQA attention is enabled by default on `GPU` and disabled on other devices. Set to a positive integer to force-enable, or `0` to force-disable. |
| `GGML_OPENVINO_MEMORY_OPTIMIZE` | Boolean | `0` | Umbrella switch for compile-time memory reductions. Enables `GGML_OPENVINO_REDUCE_COMPILE_MEM` and, on GPU, `GGML_OPENVINO_RELEASE_WEIGHTS` unless those fine-grained variables are explicitly set. |
| `GGML_OPENVINO_REDUCE_COMPILE_MEM`| Boolean | inherits from `GGML_OPENVINO_MEMORY_OPTIMIZE` | Reduce compile-time host memory use by streaming weight requantization and avoiding extra weight-node materialization where possible. Set explicitly to override the umbrella switch. |
| `GGML_OPENVINO_RELEASE_WEIGHTS` | Boolean | inherits from `GGML_OPENVINO_MEMORY_OPTIMIZE` on GPU | GPU-only. Release host weight buffers after the compiled model cache can reuse the device/plugin copy. Requires stable graph shapes; dynamic workloads that need recompilation should leave this disabled. |
| `GGML_OPENVINO_SPILL_DIR` | String | `not set` | Directory for a disk-backed weight buffer. When set, the repacked weight buffer is mapped from an unlinked file on this path instead of anonymous memory, so its pages are reclaimable under memory pressure instead of staying pinned, cutting the load-time host memory peak. Must point at real storage; a tmpfs mount (e.g. `/tmp` on many systems) backs it with RAM and makes the peak worse. |
| `GGML_OPENVINO_REQUANT_KQUANT` | String | `not set` | Requantize Q6_K/Q5_K weights (and matching MoE expert weights) to a 4-bit target instead of the default Q8_0_C, trading accuracy for less memory traffic. One of `q4_sym128` (Q6_K/Q5_K only), `q4_sym128_all` (Q4_K too, drops its per-group zero point), `q4_asym64_all` (Q6_K/Q5_K/Q4_K, keeps a real zero point at group 64), or `native` (no requantization). |
| `GGML_OPENVINO_PROFILING` | Boolean | `0` | Enable execution-time profiling. |
| `GGML_OPENVINO_DUMP_CGRAPH` | Boolean | `0` | Dump the GGML compute graph to `cgraph_ov.txt`. |
| `GGML_OPENVINO_DUMP_IR` | Boolean | `0` | Serialize OpenVINO IR files with timestamps. |
Expand Down
173 changes: 163 additions & 10 deletions ggml/src/ggml-openvino/ggml-decoder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,20 @@ static std::string get_tensor_graph_input_ov_name(const GgmlOvDecoder * decoder,
if (GgmlOvDecoder::is_inp_emb(tensor, op)) {
return "embd";
}
if (decoder->is_stateful() && GgmlOvDecoder::is_inp_mask(tensor, op)) {
return std::string(tensor->name).find("swa") == std::string::npos ? "self_kq_mask" : "self_kq_mask_swa";
if (GgmlOvDecoder::is_inp_mask(tensor, op)) {
// Give the two attention masks distinct OV parameter names. build_attn_inp_kq_mask()
// names the full-attention mask and the sliding-window mask identically, so keying a
// parameter off the name alone makes the second mask overwrite the first and both
// attention types read one parameter. Tell them apart by tensor identity, using the
// SWA classification computed in compute_llm_params(). An empty swa_layers set means
// there is only one mask in play and the plain name is correct.
const bool is_swa = decoder->is_swa_mask(tensor);
if (decoder->is_stateful()) {
return is_swa ? "self_kq_mask_swa" : "self_kq_mask";
}
if (is_swa) {
return get_tensor_ov_name(cgraph, tensor) + "_swa";
}
}
return get_tensor_ov_name(cgraph, tensor);
}
Expand Down Expand Up @@ -469,6 +481,40 @@ std::optional<int> extract_layer_from_name(const std::string & name) {
return layer;
}

// Recover the sliding window width from ggml's own SWA mask. llama.cpp never passes n_swa to a
// backend, but fill_mask() writes it into the mask: a query row keeps exactly the cells inside
// its window, so the widest row counts min(pos + 1, n_swa) unmasked cells. Counting rather than
// looking for a contiguous band is what makes this work on the KV-cache mask, where columns are
// physical cache cells in arbitrary order, not positions.
// Assumes LLAMA_SWA_TYPE_STANDARD, the only type the caller reconstructs.
static int get_swa_window_from_mask(const ggml_tensor * mask) {
if (mask->data == nullptr || !ggml_backend_buffer_is_host(mask->buffer)) {
return -1;
}
if (mask->type != GGML_TYPE_F16 && mask->type != GGML_TYPE_F32) {
return -1;
}

const int64_t n_kv = mask->ne[0];
const int64_t n_tokens = mask->ne[1];
int64_t window = 0;

for (int64_t r = 0; r < n_tokens; r++) {
int64_t kept = 0;
for (int64_t c = 0; c < n_kv; c++) {
const size_t i = (size_t) r * n_kv + c;
const float v = mask->type == GGML_TYPE_F16 ? ggml_fp16_to_fp32(((const ggml_fp16_t *) mask->data)[i]) :
((const float *) mask->data)[i];
if (v > -INFINITY) {
kept++;
}
}
window = std::max(window, kept);
}

return window > 0 ? (int) window : -1;
}

std::pair<ModelParams, ComputeParams> GgmlOvDecoder::compute_llm_params(ggml_cgraph * cgraph, bool is_static) {
ModelParams model_params;
ComputeParams compute_params;
Expand Down Expand Up @@ -522,6 +568,97 @@ std::pair<ModelParams, ComputeParams> GgmlOvDecoder::compute_llm_params(ggml_cgr
return -1;
};

// Resolve the attention mask an attention node consumes, mirroring the src layout that
// get_attention_pattern_case() classifies. Used by the SWA pre-pass below.
auto get_attention_op_mask = [&get_attention_pattern_case](const ggml_tensor * node) -> const ggml_tensor * {
switch (get_attention_pattern_case(node)) {
case 0:
case 1:
return node->src[3];
case 2:
case 3:
return node->src[1];
default:
return nullptr;
}
};

// Pre-pass: classify sliding-window vs full-attention layers.
//
// An interleaved-SWA model keeps two KV caches and two attention masks, and hands each layer
// whichever pair matches its attention type. The mask tensor does not say which is which: both
// are named "attn_inp_kq_mask" by build_attn_inp_kq_mask(), and both carry the same n_kv because
// llama_kv_cache::get_n_kv() pads occupancy up to a common multiple.
//
// The KV cache does say. Each cache allocates cache_k_l<N> once at load time with its own cell
// count: the windowed cache is sized from the window
// (PAD(min(size_base, n_swa*(unified ? n_seq_max : 1) + n_ubatch), 256), see
// llama_kv_cache_iswa), the full-attention one spans the whole context. Read the LEAF buffer
// behind the VIEW rather than the VIEW itself: the leaf extent is a constant per layer, known
// from the first graph onwards, while the view grows with context depth and would invert the
// comparison at shallow depth.
//
// Layers whose leaf is smaller than the largest leaf are the windowed ones. When every layer
// reports the same extent there is no distinction to draw -- either the model has no windowed
// layers, or the window is at least as large as the context so the two caches coincide, in
// which case a windowed layer and a full-attention one compute the same thing.
//
// Getting this wrong is silent and severe: with the windowed layers classified as
// full-attention, permute's KV slicing uses attention_size instead of attention_size_swa. The
// two agree while the context is shorter than the window, then diverge, and the mask add fails
// shape inference ("Failed to broadcast-merge input shapes") partway into a long prompt.
{
std::map<int, int64_t> layer_extent; // layer -> leaf cache_k cell count
std::map<int, const ggml_tensor *> layer_mask; // layer -> mask it consumes
int64_t max_extent = 0;

for (int i = 0; i < cgraph->n_nodes; i++) {
const ggml_tensor * mask = get_attention_op_mask(cgraph->nodes[i]);
if (mask == nullptr) {
continue;
}
const ggml_tensor * cache_k_permute = nullptr;
switch (get_attention_pattern_case(cgraph->nodes[i])) {
case 0: cache_k_permute = cgraph->nodes[i]->src[1]; break;
case 1: cache_k_permute = cgraph->nodes[i]->src[1]->src[0]; break;
case 2: cache_k_permute = cgraph->nodes[i]->src[0]->src[0]; break;
default: cache_k_permute = cgraph->nodes[i]->src[0]->src[0]->src[0]; break;
}
const ggml_tensor * cache_k_view = cache_k_permute->src[0];
if (cache_k_view->op != GGML_OP_VIEW) {
continue;
}
const ggml_tensor * leaf = cache_k_view->src[0];
auto layer = extract_layer_from_name(leaf->name);
if (!layer.has_value()) {
continue;
}
layer_extent[layer.value()] = leaf->ne[1];
layer_mask[layer.value()] = mask;
max_extent = std::max(max_extent, leaf->ne[1]);
}

for (const auto & [layer, extent] : layer_extent) {
if (extent < max_extent) {
model_params.swa_layers.push_back(layer);
if (model_params.swa_mask == nullptr) {
model_params.swa_mask = layer_mask[layer];
}
}
}
std::sort(model_params.swa_layers.begin(), model_params.swa_layers.end());

if (ggml_openvino_getenv_int("GGML_OPENVINO_LOG_SWA_LAYERS")) {
std::string per_layer;
for (const auto & [layer, extent] : layer_extent) {
per_layer += " " + std::to_string(layer) + ":" + std::to_string(extent) +
(extent < max_extent ? "(swa)" : "");
}
GGML_LOG_WARN("ov-swa: attn_layers=%zu max_extent=%ld swa_layers=%zu |%s\n", layer_extent.size(),
(long) max_extent, model_params.swa_layers.size(), per_layer.c_str());
}
}

bool rope_seen = false;
for (int i = 0; i < cgraph->n_nodes; i++) {
auto * node = cgraph->nodes[i];
Expand Down Expand Up @@ -567,11 +704,14 @@ std::pair<ModelParams, ComputeParams> GgmlOvDecoder::compute_llm_params(ggml_cgr
ggml_tensor * cache_k = cache_k_view->src[0];
int layer = extract_layer_from_name(cache_k->name).value();

std::string mask_name(mask->name);
// Classified by the pre-pass above, which groups layers by mask tensor identity. The
// mask NAME cannot be used: build_attn_inp_kq_mask() gives both masks the same name.
const bool layer_is_swa = std::find(model_params.swa_layers.begin(), model_params.swa_layers.end(),
layer) != model_params.swa_layers.end();

model_params.kv_buffer_ctx_id = ggml_backend_openvino_buffer_get_ctx_id(cache_k->buffer);
if (mask_name.find("swa") != std::string::npos) {
model_params.swa_layers.push_back(layer);
model_params.n_heads_kv_per_layer[layer] = cache_k_permute->ne[2];
if (layer_is_swa) {
model_params.ctx_per_seq_swa = cache_k->ne[1];
} else {
model_params.ctx_per_seq = cache_k->ne[1];
Expand All @@ -584,8 +724,9 @@ std::pair<ModelParams, ComputeParams> GgmlOvDecoder::compute_llm_params(ggml_cgr
memcpy(&offset, cache_k_view->op_params, sizeof(size_t));
compute_params.seq_active_start = offset / seq_size;

if (mask_name.find("swa") != std::string::npos) {
if (layer_is_swa) {
compute_params.attention_size_swa = mask->ne[0];
compute_params.swa_window = get_swa_window_from_mask(mask);
} else {
compute_params.attention_size = mask->ne[0];
}
Expand Down Expand Up @@ -725,11 +866,19 @@ ov::PartialShape GgmlOvDecoder::get_graph_input_shape(const ggml_tensor * op,
if (is_stateful()) {
// Convert stateless KV cache layout [1, 1, seq, n_heads_kv * head_size]
// to stateful layout [1, seq, n_heads_kv, head_size].
// NOTE: Gemma4 uses per-layer-type KV shapes, so no single scalar describes every
// layer. E2B varies only the head size (sliding 256, full 512); 12B also varies the
// head COUNT (sliding 8 x 256, full 1 x 512). Take the head count for this tensor's
// own layer type and derive the head size from its own combined dim, so both layer
// types get the correct split. Using the model-level count split 12B's sliding
// states as 1 x 2048 and decoded garbage.
assert(input_shape.size() == 4 && input_shape[0] == 1 && input_shape[1] == 1 &&
input_shape[2].is_dynamic() &&
input_shape[3] == (m_model_params.n_heads_kv * m_model_params.head_size));
input_shape = {input_shape[0], ov::Dimension::dynamic(), m_model_params.n_heads_kv,
m_model_params.head_size};
input_shape[2].is_dynamic() && input_shape[3].is_static());
const int n_heads_kv = get_n_heads_kv_for_tensor(input);
assert(n_heads_kv > 0 && input_shape[3].get_length() % n_heads_kv == 0);
const int64_t combined_dim = input_shape[3].get_length(); // n_heads_kv * head_size
const int64_t head_size = combined_dim / n_heads_kv;
input_shape = {input_shape[0], ov::Dimension::dynamic(), n_heads_kv, head_size};
}

} else if (is_kv_idx(input, op)) {
Expand Down Expand Up @@ -800,6 +949,10 @@ void GgmlOvDecoder::add_extra_inputs() {
if (m_compute_params.attention_size_swa != -1) {
create_1d_input("attention_size_swa", m_compute_params.attention_size_swa);
}
// only the stateful SWA mask consumes this
if (is_stateful() && m_compute_params.swa_window != -1) {
create_1d_input("swa_window", m_compute_params.swa_window);
}
create_1d_input("n_seq_active", m_compute_params.n_seq_active);
create_1d_input("seq_active_start", m_compute_params.seq_active_start);
create_1d_input("seq_active_end", m_compute_params.seq_active_start + m_compute_params.n_seq_active);
Expand Down
Loading