Skip to content

Commit 7797071

Browse files
Add EntropyGatedChunkKVPress
Co-authored-by: Liran Azran <liran.azr90@gmail.com> Signed-off-by: Shahar Ben-Ishay <shahar.benishay@gmail.com>
1 parent 4e41f14 commit 7797071

6 files changed

Lines changed: 345 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ Finally we provide wrapper presses that can be combined with other presses:
140140
- `ComposedPress` ([source](kvpress/presses/composed_press.py)): compose multiple presses together by chaining their forward hooks
141141
- `KeyRerotationPress` ([source](kvpress/presses/key_rerotation_press.py)): rerotate pruned keys to have continuous RoPE embeddings
142142
- `ChunkKVPress` ([source](kvpress/presses/chunkkv_press.py), [paper](https://arxiv.org/abs/2502.00299)): compress by selecting important chunks, preserving semantic coherence
143+
- `EntropyGatedChunkKVPress` ([source](kvpress/presses/entropy_gated_chunkkv_press.py)): like `ChunkKVPress`, but reduces important chunks whose score mass is concentrated (using entropy from information theory) in a few tokens to their top-`rescue_size` tokens, reallocating the freed budget to more chunks
143144
- `ChunkPress` ([source](kvpress/presses/chunk_press.py), [paper](https://direct.mit.edu/tacl/article/doi/10.1162/tacl_a_00716/125280)): compress the KV cache on each sequence chunk separately. This can yield to more uniform compression across long sequences
144145
- `CriticalKVPress` and `CriticalAdaKVPress` ([source](kvpress/presses/criticalkv_press.py), [paper](https://arxiv.org/abs/2502.03805)): refine the scores using the L1 norm of Wo @ values, coupled with a two-stage selection.
145146
- `BlockPress` ([source](kvpress/presses/block_press.py), [paper](https://arxiv.org/abs/2504.15364)): segment input sequence into non-overlapping blocks and compress iteratively (⚠️ not a true chunked-prefill implementation)

evaluation/evaluate_registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
DecodingPress,
2626
DMSPress,
2727
DuoAttentionPress,
28+
EntropyGatedChunkKVPress,
2829
ExpectedAttentionPress,
2930
FastKVzipPress,
3031
FinchPress,
@@ -87,6 +88,7 @@
8788
"cur": CURPress(),
8889
"duo_attention": DuoAttentionPress(),
8990
"duo_attention_on_the_fly": DuoAttentionPress(on_the_fly_scoring=True),
91+
"entropy_gated_chunkkv": EntropyGatedChunkKVPress(press=SnapKVPress(), chunk_length=10, rescue_size=4),
9092
"expected_attention": AdaKVPress(ExpectedAttentionPress(epsilon=1e-2)),
9193
"fastkvzip": FastKVzipPress(),
9294
"finch": FinchPress(),

kvpress/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from kvpress.presses.decoding_press import DecodingPress
2020
from kvpress.presses.dms_press import DMSPress
2121
from kvpress.presses.duo_attention_press import DuoAttentionPress
22+
from kvpress.presses.entropy_gated_chunkkv_press import EntropyGatedChunkKVPress
2223
from kvpress.presses.expected_attention_press import ExpectedAttentionPress
2324
from kvpress.presses.expected_attention_with_stats import ExpectedAttentionStatsPress
2425
from kvpress.presses.fastkvzip_press import FastKVzipPress
@@ -97,4 +98,5 @@
9798
"MergingPress",
9899
"CapPress",
99100
"LUKVPress",
101+
"EntropyGatedChunkKVPress",
100102
]
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 1993-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import math
5+
from dataclasses import dataclass
6+
from typing import Optional
7+
8+
import torch
9+
from torch import nn
10+
11+
from kvpress.presses.base_press import BasePress
12+
from kvpress.presses.scorer_press import ScorerPress
13+
14+
15+
@dataclass
16+
class EntropyGatedChunkKVPress(BasePress):
17+
"""
18+
EntropyGatedChunkKV: chunk selection gated by within-chunk score entropy.
19+
20+
Extends ChunkKVPress, which keeps or drops every chunk as a whole. A chunk whose
21+
importance comes from a single high-scoring token therefore spends chunk_length
22+
cache slots to preserve one useful token. This press measures the normalized
23+
entropy of the token scores inside each chunk: coherent chunks (high entropy) are
24+
kept whole, while important but spiky chunks (low entropy) are reduced to their
25+
top rescue_size tokens, and the freed budget is spent on further chunks. The
26+
number of retained tokens is exactly (1 - compression_ratio) * kv_len, matching
27+
the budget of ChunkKVPress.
28+
29+
Based on ChunkKV (https://arxiv.org/abs/2502.00299).
30+
31+
Parameters
32+
----------
33+
press : ScorerPress
34+
The underlying scoring method used to compute global importance scores.
35+
chunk_length : int, default=10
36+
Length of each chunk for token selection. Shorter than the ChunkKVPress default
37+
of 20: a finer granularity gives the gate more chunks to reallocate budget
38+
between, which is where the gain comes from.
39+
rescue_size : int, default=4
40+
Number of tokens kept from an important but spiky chunk.
41+
entropy_threshold : float or None, default=None
42+
Spikiness cutoff on the normalized within-chunk entropy, in [0, 1]. A chunk is
43+
spiky when its entropy falls below this value. If None, the per-example median
44+
entropy over all chunks is used.
45+
46+
Notes
47+
-----
48+
Chunk and token selection is shared across heads and computed from batch element 0,
49+
the same convention as ChunkKVPress; it is intended for the batch-size-1 context
50+
compression performed by the kvpress pipeline. Token scores are assumed to be
51+
non-negative (as produced by e.g. SnapKVPress) and are clamped before the entropy
52+
is computed.
53+
"""
54+
55+
press: ScorerPress
56+
chunk_length: int = 10
57+
rescue_size: int = 4
58+
entropy_threshold: Optional[float] = None
59+
60+
def __post_init__(self):
61+
assert isinstance(self.press, ScorerPress), "EntropyGatedChunkKVPress requires a ScorerPress as input"
62+
63+
def post_init_from_model(self, model):
64+
self.press.post_init_from_model(model)
65+
66+
@property
67+
def compression_ratio(self):
68+
return self.press.compression_ratio
69+
70+
@compression_ratio.setter
71+
def compression_ratio(self, value):
72+
self.press.compression_ratio = value
73+
74+
def compress(
75+
self,
76+
module: nn.Module,
77+
hidden_states: torch.Tensor,
78+
keys: torch.Tensor,
79+
values: torch.Tensor,
80+
attentions: torch.Tensor,
81+
kwargs: dict,
82+
) -> tuple[torch.Tensor, torch.Tensor]:
83+
if self.press.compression_ratio == 0:
84+
return keys, values
85+
assert attentions is None, "EntropyGatedChunkKVPress does not support attentions."
86+
87+
eps = 1e-8
88+
kv_len = keys.shape[2]
89+
c = self.chunk_length
90+
91+
# Head-summed, non-negative per-token scores (batch element 0).
92+
global_scores = self.press.score(module, hidden_states, keys, values, attentions, kwargs)
93+
tok = global_scores.sum(dim=1)[0].clamp(min=0).float() # (kv_len,)
94+
95+
budget = max(1, int(kv_len * (1 - self.press.compression_ratio)))
96+
if budget >= kv_len:
97+
return keys, values
98+
99+
# 1. Per-chunk semantic score S and normalized entropy H_tilde.
100+
n_chunks = math.ceil(kv_len / c)
101+
bounds = [(i * c, min(i * c + c, kv_len)) for i in range(n_chunks)]
102+
n_complete = kv_len // c
103+
remaining_tokens = kv_len % c
104+
105+
# Per-chunk statistics are computed vectorized rather than in a Python loop, which
106+
# would launch O(n_chunks) tiny kernels per forward pass. Complete chunks all hold
107+
# exactly c tokens, so reshaping to (n_complete, c) makes each row one chunk and the
108+
# row-wise reductions give its mean and normalized Shannon entropy.
109+
X = tok[: n_complete * c].view(n_complete, c)
110+
s_scores = X.mean(dim=1)
111+
if c > 1:
112+
p = X / (X.sum(dim=1, keepdim=True) + eps)
113+
h = -(p * (p + eps).log()).sum(dim=1)
114+
ht = (h / math.log(c)).clamp(0.0, 1.0)
115+
else:
116+
# Entropy is undefined for a single token, so such a chunk is treated as spiky,
117+
# as for a length-1 trailing chunk below. Normalizing by log(1) = 0 would divide
118+
# by zero here, which is why this case is handled separately.
119+
ht = torch.zeros(n_complete, device=tok.device)
120+
121+
# The trailing partial chunk does not fit the reshape and is handled separately.
122+
# Entropy is undefined for a single token, so such a chunk is treated as spiky.
123+
if remaining_tokens > 0:
124+
ts = tok[n_complete * c :]
125+
s_tail = ts.mean().unsqueeze(0)
126+
if remaining_tokens >= 2:
127+
pr = ts / (ts.sum() + eps)
128+
hr = -(pr * (pr + eps).log()).sum()
129+
ht_tail = (hr / math.log(remaining_tokens)).clamp(0.0, 1.0).unsqueeze(0)
130+
else:
131+
ht_tail = torch.zeros(1, device=tok.device)
132+
s_scores = torch.cat([s_scores, s_tail])
133+
ht = torch.cat([ht, ht_tail])
134+
135+
med = s_scores.median()
136+
if self.entropy_threshold is None:
137+
tau = ht.median()
138+
else:
139+
tau = torch.tensor(float(self.entropy_threshold), device=tok.device)
140+
141+
# 2. Greedy pass over chunks in decreasing semantic score.
142+
# The loop is inherently sequential because the budget is consumed in order. Both
143+
# gating masks are therefore computed vectorized and moved to CPU lists once: reading
144+
# a GPU scalar per iteration would force a synchronize and serialize the loop. The
145+
# topk calls stay on the GPU tensor so their tie-breaking is unchanged.
146+
important_all = (s_scores >= med).tolist()
147+
spiky_all = (ht < tau).tolist()
148+
keep = torch.zeros(kv_len, dtype=torch.bool, device=tok.device)
149+
for i in torch.argsort(s_scores, descending=True).tolist():
150+
if budget <= 0:
151+
break
152+
s, e = bounds[i]
153+
n_i = e - s
154+
ts = tok[s:e]
155+
156+
if important_all[i] and spiky_all[i]:
157+
# Important but spiky: keep only the highest-scoring tokens of the chunk.
158+
r = min(self.rescue_size, budget, n_i)
159+
keep[torch.topk(ts, r).indices + s] = True
160+
budget -= r
161+
elif n_i <= budget:
162+
# Coherent chunk that fits in the remaining budget: keep it whole.
163+
keep[s:e] = True
164+
budget -= n_i
165+
else:
166+
# Last chunk to be considered: keep as much of it as the budget allows.
167+
keep[torch.topk(ts, budget).indices + s] = True
168+
budget = 0
169+
170+
# 3. Reducing spiky chunks may leave budget unspent. Top up with the highest-scoring
171+
# remaining tokens so that exactly (1 - compression_ratio) * kv_len tokens are kept.
172+
if budget > 0:
173+
leftover = (~keep).nonzero(as_tuple=False).squeeze(-1)
174+
if leftover.numel() > 0:
175+
add = min(budget, leftover.numel())
176+
keep[leftover[torch.topk(tok[leftover], add).indices]] = True
177+
178+
# 4. Gather the retained keys and values in positional order.
179+
indices = keep.nonzero(as_tuple=False).squeeze(-1).sort()[0]
180+
indices = indices.view(1, 1, -1, 1).expand(keys.shape[0], keys.shape[1], -1, module.head_dim)
181+
keys = keys.gather(2, indices).contiguous()
182+
values = values.gather(2, indices).contiguous()
183+
return keys, values
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 1993-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from dataclasses import dataclass, field
5+
6+
import pytest
7+
import torch
8+
from torch import nn
9+
10+
from kvpress import EntropyGatedChunkKVPress
11+
from kvpress.presses.scorer_press import ScorerPress
12+
13+
14+
@dataclass
15+
class FixedScorer(ScorerPress):
16+
"""Scorer returning pre-set token scores, so chunk statistics are fully controlled."""
17+
18+
scores: torch.Tensor = field(default_factory=lambda: torch.empty(0))
19+
20+
def score(self, module, hidden_states, keys, values, attentions, kwargs):
21+
return self.scores
22+
23+
24+
class DummyAttention(nn.Module):
25+
def __init__(self, head_dim):
26+
super().__init__()
27+
self.head_dim = head_dim
28+
29+
30+
def run_press(scores, press):
31+
"""Run compress on keys whose values encode their position, and return the kept positions."""
32+
kv_len = scores.shape[2]
33+
n_heads, head_dim = scores.shape[1], 4
34+
positions = torch.arange(kv_len, dtype=torch.float32)
35+
keys = positions.view(1, 1, kv_len, 1).expand(1, n_heads, kv_len, head_dim).contiguous()
36+
values = keys.clone()
37+
out_keys, out_values = press.compress(DummyAttention(head_dim), None, keys, values, None, {})
38+
assert torch.equal(out_keys, out_values)
39+
return out_keys[0, 0, :, 0].long().tolist()
40+
41+
42+
def expected_budget(kv_len, compression_ratio):
43+
"""The token budget the press targets, matching ChunkKVPress."""
44+
return max(1, int(kv_len * (1 - compression_ratio)))
45+
46+
47+
def spiky_scores(n_chunks, chunk_length, n_heads=2):
48+
"""One dominant needle per chunk, the rest near-zero: every chunk is important and spiky."""
49+
kv_len = n_chunks * chunk_length
50+
scores = torch.full((1, n_heads, kv_len), 0.01)
51+
needles = [i * chunk_length + (i % chunk_length) for i in range(n_chunks)]
52+
for rank, idx in enumerate(needles):
53+
scores[0, :, idx] = 10.0 + rank # distinct so chunk ordering is deterministic
54+
return scores, needles
55+
56+
57+
@pytest.mark.parametrize("compression_ratio", [0.1, 0.25, 0.5, 0.75, 0.9])
58+
@pytest.mark.parametrize("chunk_length", [1, 4, 10, 20])
59+
@pytest.mark.parametrize("kv_len", [100, 251])
60+
def test_retains_exact_budget(compression_ratio, chunk_length, kv_len):
61+
"""The retained token count matches ChunkKVPress's budget exactly, including partial chunks."""
62+
torch.manual_seed(0)
63+
scores = torch.rand(1, 2, kv_len)
64+
press = EntropyGatedChunkKVPress(
65+
press=FixedScorer(compression_ratio=compression_ratio, scores=scores),
66+
chunk_length=chunk_length,
67+
rescue_size=4,
68+
)
69+
kept = run_press(scores, press)
70+
assert len(kept) == expected_budget(kv_len, compression_ratio)
71+
assert kept == sorted(set(kept)), "kept positions must be unique and in positional order"
72+
73+
74+
def test_spiky_chunk_is_reduced_to_rescue_size():
75+
"""An important but spiky chunk keeps only its needle, not all chunk_length tokens."""
76+
chunk_length, n_chunks, rescue_size = 10, 20, 1
77+
scores, needles = spiky_scores(n_chunks, chunk_length)
78+
kv_len = n_chunks * chunk_length
79+
# A budget of ~2 chunks: ChunkKV would spend it keeping 2 chunks whole, EG-ChunkKV rescues needles.
80+
press = EntropyGatedChunkKVPress(
81+
press=FixedScorer(compression_ratio=0.9, scores=scores),
82+
chunk_length=chunk_length,
83+
rescue_size=rescue_size,
84+
entropy_threshold=0.5,
85+
)
86+
kept = run_press(scores, press)
87+
assert len(kept) == expected_budget(kv_len, 0.9)
88+
89+
# Every rescued needle survives, and the highest-scoring chunks are not kept whole.
90+
kept_set = set(kept)
91+
top_needles = sorted(needles, key=lambda i: -float(scores[0, 0, i]))[:20]
92+
assert kept_set.issuperset(top_needles[:10]), "the strongest needles must be retained"
93+
per_chunk = [len([k for k in kept if k // chunk_length == c]) for c in range(n_chunks)]
94+
assert max(per_chunk) < chunk_length, "no spiky chunk should be kept whole"
95+
96+
97+
def test_entropy_threshold_degenerate_limits():
98+
"""threshold=0 disables rescuing (chunks kept whole); threshold=1 rescues every important chunk."""
99+
chunk_length, n_chunks = 10, 20
100+
scores, _ = spiky_scores(n_chunks, chunk_length)
101+
kwargs = dict(chunk_length=chunk_length, rescue_size=1)
102+
103+
never_spiky = EntropyGatedChunkKVPress(
104+
press=FixedScorer(compression_ratio=0.9, scores=scores), entropy_threshold=0.0, **kwargs
105+
)
106+
always_spiky = EntropyGatedChunkKVPress(
107+
press=FixedScorer(compression_ratio=0.9, scores=scores), entropy_threshold=1.0, **kwargs
108+
)
109+
kept_whole = run_press(scores, never_spiky)
110+
kept_rescued = run_press(scores, always_spiky)
111+
112+
# Both spend exactly the same budget
113+
budget = expected_budget(n_chunks * chunk_length, 0.9)
114+
assert len(kept_whole) == len(kept_rescued) == budget
115+
116+
# With rescuing disabled the budget goes to whole chunks; with it enabled the same budget
117+
# is spread over strictly more chunks, which is the point of the press.
118+
chunks_whole = len({k // chunk_length for k in kept_whole})
119+
chunks_rescued = len({k // chunk_length for k in kept_rescued})
120+
assert chunks_whole == 2, "without rescuing, a 20-token budget buys exactly 2 whole chunks"
121+
assert chunks_rescued > chunks_whole
122+
123+
124+
def test_compression_ratio_is_delegated_to_inner_press():
125+
"""The wrapper exposes and forwards the inner ScorerPress's compression ratio."""
126+
inner = FixedScorer(compression_ratio=0.3, scores=torch.rand(1, 2, 64))
127+
press = EntropyGatedChunkKVPress(press=inner, chunk_length=8)
128+
assert press.compression_ratio == 0.3
129+
press.compression_ratio = 0.7
130+
assert inner.compression_ratio == 0.7
131+
132+
133+
def test_requires_scorer_press():
134+
with pytest.raises(AssertionError):
135+
EntropyGatedChunkKVPress(press="not-a-press") # type: ignore[arg-type]

0 commit comments

Comments
 (0)