Feature Request: SWA KV cache compression for Gemma 4 (and other SWA models)
Prerequisites
Feature Description
Implement sliding-window-attention (SWA) KV cache compression, so that SWA layers allocate only window-sized KV storage rather than full-context KV. This was suggested by @ikawrakow in #1594:
"You can add a feature request to implement KV compression for SWA layers, along with checkpointing, which becomes necessary in that case."
Filing this as a standalone tracking issue since none exists yet.
Motivation
Gemma 4 31B architecture (from the official config.json)
num_hidden_layers: 60
layer_types: 5× sliding_attention followed by 1× full_attention, repeated 10 times → 50 SWA layers, 10 global layers
sliding_window: 1024 tokens
max_position_embeddings: 262144
KV cache size with vs without SWA compression
Per-layer KV cost scales linearly with the number of tokens stored. Without SWA compression, ik_llama.cpp allocates the full context length of KV for every layer. With SWA compression, only the 1024-token window is allocated for SWA layers; global layers still get the full context.
Ratio of SWA-compressed KV to uncompressed, as a function of context size N:
compressed_kv 50 * 1024 + 10 * N
-------------- = --------------------
uncompressed_kv 60 * N
| context N |
compressed / uncompressed |
effective KV savings |
| 2048 |
58% |
42% |
| 8192 |
27% |
73% |
| 32768 |
19% |
81% |
| 65536 |
18% |
82% |
| 262144 |
17% |
83% |
So at any context past ~8K, SWA compression eliminates ~70-83% of the KV cache memory.
Observed impact on our hardware
3× RTX 3090 (72 GB total VRAM), ik_llama.cpp commit 13d7178d, tuned config with -sm graph -ctk q6_0 -ctv q6_0 -khad -vhad:
| model |
backend |
context tested |
| Gemma 4 31B dense |
llama.cpp (master, q8_0 KV) |
262144 (model native max, runs comfortably) |
| Gemma 4 31B dense |
ik_llama.cpp (q6_0 KV + -khad -vhad) |
~65536 (higher values OOM on load) |
llama.cpp runs at the model's full native context (262144) on this hardware without strain. ik_llama.cpp on the same hardware OOMs during load when I push the context past ~65K, despite using a smaller KV quant and Hadamard transforms. 262144 is the model's trained max_position_embeddings, not a tested ceiling for llama.cpp — I haven't probed beyond it because that's outside the model's valid range. So the observed difference is "runs at model native max" vs "caps around 1/4 of native max". The memory savings table above is consistent with a ~6× ratio between the two KV allocations; I can't prove KV is the only contributor to the observed cap difference, but it is a plausible primary explanation given the architecture.
Throughput numbers
These are included for context only; the request is motivated by the context cap, not by the throughput gap. Tuned -b/-ub via ik-llama-bench and llama-bench on the same hardware. pp512 / tg128 with fa on.
Gemma 4 31B dense (Unsloth UD-Q6_K_XL):
| backend |
config |
PP tok/s |
TG tok/s |
llama.cpp |
-sm layer -b 512 -ub 128 -ctk q8_0 -ctv q8_0 |
1020 |
25.0 |
ik_llama.cpp |
-sm graph -muge -b 2048 -ub 512 -ctk q6_0 -ctv q6_0 -khad -vhad |
1474 |
36.6 |
Gemma 4 26B-A4B MoE (Unsloth UD-Q8_K_XL):
| backend |
config |
PP tok/s |
TG tok/s |
llama.cpp |
-sm layer -b 2048 -ub 512 -ctk q8_0 -ctv q8_0 |
3154 |
97.8 |
ik_llama.cpp |
-sm graph -muge -b 2048 -ub 1024 -ctk q6_0 -ctv q6_0 -khad -vhad |
3054 |
73.3 |
The primary motivation for this request is the context cap, not TG throughput — I can't isolate which of several differences (SWA compression, attention kernel, MoE dispatch, etc.) accounts for the 26B TG gap, so I'm not claiming SWA compression as its cause. The context cap impact, however, is directly explained by the KV size table above.
Quality cost
SWA compression is an architectural optimization, not a quantization. SWA layers attend only to the window by definition, so KV outside the window is not used by the model's math. Discarding it is lossless.
The -khad/-vhad + q6_0 KV combination is also near-lossless per @ikawrakow's own data in #1599:
| KV config |
Gemma 4 31B PPL |
| f16 |
5.2306 |
| q8_0 |
5.2311 |
q6_0 + -vhad |
5.2323 |
So this feature request is for a memory optimization with no expected quality cost on its own.
Possible Implementation
Where the gap is in ik_llama.cpp today
ik_llama.cpp already has most of the SWA machinery in place:
llama_hparams::swa_layers[LLAMA_MAX_LAYERS] — per-layer SWA flag array (src/llama-hparams.h:144)
n_swa, n_swa_pattern — window size and pattern period (src/llama-hparams.h:30-31)
n_embd_head_k_swa, n_embd_head_v_swa, n_rot_swa, rope_freq_base_train_swa — per-SWA hparams
build_inp_KQ_mask_swa() / inp_KQ_mask_swa — SWA-aware attention mask builder (src/llama-build-context.h:130, src/llama-context.h:203)
So the attention math already masks to the window correctly. The missing piece is purely in KV cache sizing.
In llama_kv_cache_init() (src/llama.cpp:854 onwards), the per-layer K/V tensors are allocated as:
// src/llama.cpp:956
k = ggml_new_tensor_2d(ctx, this_type_k, n_embd_head_k, n_head_kv * kv_size);
...
// src/llama.cpp:958
int64_t v_ne = int64_t(n_embd_v_row) * kv_size;
// src/llama.cpp:969
v = ggml_new_tensor_1d(ctx, this_type_v, v_ne);
Every layer uses the global kv_size, regardless of whether hparams.swa_layers[i] is set. This is the single point where SWA layers pay the full-context memory cost they don't need.
How llama.cpp solves it
llama.cpp implements this via composition in src/llama-kv-cache-iswa.{h,cpp}. The core design is a wrapper class that holds two existing llama_kv_cache instances, filtered per-layer by SWA-ness:
// llama-kv-cache-iswa.cpp — ctor, edited for brevity
const layer_filter_cb filter_base = [&](int32_t il) { return !hparams.is_swa(il); };
const layer_filter_cb filter_swa = [&](int32_t il) { return hparams.is_swa(il); };
const uint32_t size_base = kv_size;
uint32_t size_swa = GGML_PAD(std::min(size_base,
hparams.n_swa * (unified ? n_seq_max : 1) + n_ubatch),
256);
if (swa_full) { size_swa = size_base; }
kv_base = std::make_unique<llama_kv_cache>(
model, type_k, type_v, v_trans, offload, unified,
size_base, n_seq_max, n_pad,
0, LLAMA_SWA_TYPE_NONE, filter_base, reuse);
kv_swa = std::make_unique<llama_kv_cache>(
model, type_k, type_v, v_trans, offload, unified,
size_swa, n_seq_max, n_pad,
hparams.n_swa, hparams.swa_type, filter_swa, reuse);
All seq ops (seq_rm, seq_cp, seq_keep, seq_add, seq_div) just delegate to both sub-caches in order (llama-kv-cache-iswa.cpp:80-107). The per-layer routing is done entirely through filter_base / filter_swa, which the existing llama_kv_cache constructor already accepts.
Key supporting pieces in llama.cpp:
llama_swa_type enum (src/llama-hparams.h:19-24): NONE / STANDARD / CHUNKED / SYMMETRIC.
llama_hparams::is_swa(il) — per-layer predicate populated from the model's layer_types.
llama_hparams::is_masked_swa(n_swa, swa_type, p0, p1) (src/llama-hparams.h:316-350) — the window-mask helper used by the attention mask builder.
- Per-arch model files use the ISWA variant when
swa_type != NONE: src/models/gemma2-iswa.cpp, gemma3n-iswa.cpp, gemma4-iswa.cpp, cohere2-iswa.cpp, openai-moe-iswa.cpp, mimo2-iswa.cpp, step35-iswa.cpp, llama-iswa.cpp.
- The selection point is in
llama-model.cpp: if (hparams.swa_type != LLAMA_SWA_TYPE_NONE) res = new llama_kv_cache_iswa(...) else the plain llama_kv_cache (src/llama-model.cpp:8702-8735).
- A
--swa-full cparam exists as an escape hatch: when set, the SWA sub-cache is sized to size_base, giving the legacy "uncompressed" behavior for debugging / correctness comparisons.
Mapping onto ik_llama.cpp
Two broad approaches for the port, listed from biggest to smallest architectural change:
Approach A: Compose two sub-caches (llama.cpp pattern). Introduce an ik_llama_kv_cache_iswa type that owns two llama_kv_cache instances — kv_base (sized kv_size, filtered to non-SWA layers) and kv_swa (sized n_swa + pad, filtered to SWA layers). Seq ops delegate to both. This is the cleanest mapping but requires each sub-cache to independently track its own cells / head / used, which currently live as single fields in struct llama_kv_cache (src/llama-context.h:35-85).
Approach B: Per-layer tensor sizing in the existing cache. Keep the single llama_kv_cache but allow k_l[i] / v_l[i] to have different cell counts depending on hparams.swa_layers[i]. At the SWA layer K/V allocation site above (src/llama.cpp:956-969), replace kv_size with hparams.swa_layers[i] ? swa_kv_size : kv_size, where swa_kv_size = n_swa + pad. This avoids the architectural refactor but needs a way to map logical positions into the SWA layer's smaller slot ring. That's the "context checkpointing" concern from #1594: when a position rolls out of the SWA window, the K/V tensor slot needs to be reusable even though the global cells array still references that position for non-SWA layers.
Approach A mirrors what llama.cpp did and sidesteps the slot-reuse problem by giving SWA layers their own cells. Approach B is a smaller diff but needs new logic in find_slot / seq_rm / position-shift paths to keep per-layer slot accounting consistent.
Either way, the attention mask path (build_inp_KQ_mask_swa) already exists and does not need to change. Only the allocator and the slot-tracking paths are affected.
Scope: Gemma 2, Gemma 3, Gemma 4 all use SWA; other architectures with ISWA implementations in llama.cpp master include Cohere2, OpenAI-MoE, Mimo2, Step-3.5. A single ISWA implementation would cover all of them.
Hardware / build
- EPYC 7663, 3× RTX 3090, 512 GB RAM
- Talos Linux, NVIDIA driver 595.58.03, CUDA 13.1.1
ik_llama.cpp commit 13d7178d
llama.cpp commit d6f303004 (for the comparison numbers)
Happy to rerun benchmarks against any prototype branch.
Feature Request: SWA KV cache compression for Gemma 4 (and other SWA models)
Prerequisites
ik_llama.cppcommit13d7178d, 2026-04-09).README.md.Feature Description
Implement sliding-window-attention (SWA) KV cache compression, so that SWA layers allocate only window-sized KV storage rather than full-context KV. This was suggested by @ikawrakow in #1594:
Filing this as a standalone tracking issue since none exists yet.
Motivation
Gemma 4 31B architecture (from the official
config.json)num_hidden_layers: 60layer_types: 5×sliding_attentionfollowed by 1×full_attention, repeated 10 times → 50 SWA layers, 10 global layerssliding_window: 1024 tokensmax_position_embeddings: 262144KV cache size with vs without SWA compression
Per-layer KV cost scales linearly with the number of tokens stored. Without SWA compression, ik_llama.cpp allocates the full context length of KV for every layer. With SWA compression, only the 1024-token window is allocated for SWA layers; global layers still get the full context.
Ratio of SWA-compressed KV to uncompressed, as a function of context size
N:So at any context past ~8K, SWA compression eliminates ~70-83% of the KV cache memory.
Observed impact on our hardware
3× RTX 3090 (72 GB total VRAM),
ik_llama.cppcommit13d7178d, tuned config with-sm graph -ctk q6_0 -ctv q6_0 -khad -vhad:llama.cpp(master, q8_0 KV)ik_llama.cpp(q6_0 KV +-khad -vhad)llama.cppruns at the model's full native context (262144) on this hardware without strain.ik_llama.cppon the same hardware OOMs during load when I push the context past ~65K, despite using a smaller KV quant and Hadamard transforms. 262144 is the model's trainedmax_position_embeddings, not a tested ceiling forllama.cpp— I haven't probed beyond it because that's outside the model's valid range. So the observed difference is "runs at model native max" vs "caps around 1/4 of native max". The memory savings table above is consistent with a ~6× ratio between the two KV allocations; I can't prove KV is the only contributor to the observed cap difference, but it is a plausible primary explanation given the architecture.Throughput numbers
These are included for context only; the request is motivated by the context cap, not by the throughput gap. Tuned
-b/-ubviaik-llama-benchandllama-benchon the same hardware.pp512 / tg128with fa on.Gemma 4 31B dense (Unsloth UD-Q6_K_XL):
llama.cpp-sm layer -b 512 -ub 128 -ctk q8_0 -ctv q8_0ik_llama.cpp-sm graph -muge -b 2048 -ub 512 -ctk q6_0 -ctv q6_0 -khad -vhadGemma 4 26B-A4B MoE (Unsloth UD-Q8_K_XL):
llama.cpp-sm layer -b 2048 -ub 512 -ctk q8_0 -ctv q8_0ik_llama.cpp-sm graph -muge -b 2048 -ub 1024 -ctk q6_0 -ctv q6_0 -khad -vhadThe primary motivation for this request is the context cap, not TG throughput — I can't isolate which of several differences (SWA compression, attention kernel, MoE dispatch, etc.) accounts for the 26B TG gap, so I'm not claiming SWA compression as its cause. The context cap impact, however, is directly explained by the KV size table above.
Quality cost
SWA compression is an architectural optimization, not a quantization. SWA layers attend only to the window by definition, so KV outside the window is not used by the model's math. Discarding it is lossless.
The
-khad/-vhad+ q6_0 KV combination is also near-lossless per @ikawrakow's own data in #1599:-vhadSo this feature request is for a memory optimization with no expected quality cost on its own.
Possible Implementation
Where the gap is in
ik_llama.cpptodayik_llama.cppalready has most of the SWA machinery in place:llama_hparams::swa_layers[LLAMA_MAX_LAYERS]— per-layer SWA flag array (src/llama-hparams.h:144)n_swa,n_swa_pattern— window size and pattern period (src/llama-hparams.h:30-31)n_embd_head_k_swa,n_embd_head_v_swa,n_rot_swa,rope_freq_base_train_swa— per-SWA hparamsbuild_inp_KQ_mask_swa()/inp_KQ_mask_swa— SWA-aware attention mask builder (src/llama-build-context.h:130,src/llama-context.h:203)So the attention math already masks to the window correctly. The missing piece is purely in KV cache sizing.
In
llama_kv_cache_init()(src/llama.cpp:854onwards), the per-layer K/V tensors are allocated as:Every layer uses the global
kv_size, regardless of whetherhparams.swa_layers[i]is set. This is the single point where SWA layers pay the full-context memory cost they don't need.How
llama.cppsolves itllama.cppimplements this via composition insrc/llama-kv-cache-iswa.{h,cpp}. The core design is a wrapper class that holds two existingllama_kv_cacheinstances, filtered per-layer by SWA-ness:All seq ops (
seq_rm,seq_cp,seq_keep,seq_add,seq_div) just delegate to both sub-caches in order (llama-kv-cache-iswa.cpp:80-107). The per-layer routing is done entirely throughfilter_base/filter_swa, which the existingllama_kv_cacheconstructor already accepts.Key supporting pieces in
llama.cpp:llama_swa_typeenum (src/llama-hparams.h:19-24):NONE/STANDARD/CHUNKED/SYMMETRIC.llama_hparams::is_swa(il)— per-layer predicate populated from the model'slayer_types.llama_hparams::is_masked_swa(n_swa, swa_type, p0, p1)(src/llama-hparams.h:316-350) — the window-mask helper used by the attention mask builder.swa_type != NONE:src/models/gemma2-iswa.cpp,gemma3n-iswa.cpp,gemma4-iswa.cpp,cohere2-iswa.cpp,openai-moe-iswa.cpp,mimo2-iswa.cpp,step35-iswa.cpp,llama-iswa.cpp.llama-model.cpp:if (hparams.swa_type != LLAMA_SWA_TYPE_NONE) res = new llama_kv_cache_iswa(...)else the plainllama_kv_cache(src/llama-model.cpp:8702-8735).--swa-fullcparam exists as an escape hatch: when set, the SWA sub-cache is sized tosize_base, giving the legacy "uncompressed" behavior for debugging / correctness comparisons.Mapping onto
ik_llama.cppTwo broad approaches for the port, listed from biggest to smallest architectural change:
Approach A: Compose two sub-caches (llama.cpp pattern). Introduce an
ik_llama_kv_cache_iswatype that owns twollama_kv_cacheinstances —kv_base(sizedkv_size, filtered to non-SWA layers) andkv_swa(sizedn_swa + pad, filtered to SWA layers). Seq ops delegate to both. This is the cleanest mapping but requires each sub-cache to independently track its owncells/head/used, which currently live as single fields instruct llama_kv_cache(src/llama-context.h:35-85).Approach B: Per-layer tensor sizing in the existing cache. Keep the single
llama_kv_cachebut allowk_l[i]/v_l[i]to have different cell counts depending onhparams.swa_layers[i]. At the SWA layer K/V allocation site above (src/llama.cpp:956-969), replacekv_sizewithhparams.swa_layers[i] ? swa_kv_size : kv_size, whereswa_kv_size = n_swa + pad. This avoids the architectural refactor but needs a way to map logical positions into the SWA layer's smaller slot ring. That's the "context checkpointing" concern from #1594: when a position rolls out of the SWA window, the K/V tensor slot needs to be reusable even though the globalcellsarray still references that position for non-SWA layers.Approach A mirrors what
llama.cppdid and sidesteps the slot-reuse problem by giving SWA layers their own cells. Approach B is a smaller diff but needs new logic infind_slot/seq_rm/ position-shift paths to keep per-layer slot accounting consistent.Either way, the attention mask path (
build_inp_KQ_mask_swa) already exists and does not need to change. Only the allocator and the slot-tracking paths are affected.Scope: Gemma 2, Gemma 3, Gemma 4 all use SWA; other architectures with ISWA implementations in
llama.cppmaster include Cohere2, OpenAI-MoE, Mimo2, Step-3.5. A single ISWA implementation would cover all of them.Hardware / build
ik_llama.cppcommit13d7178dllama.cppcommitd6f303004(for the comparison numbers)Happy to rerun benchmarks against any prototype branch.