|
| 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 |
0 commit comments