Skip to main content

kv_cache_size/
lib.rs

1//! Exact KV-cache arithmetic for transformer inference.
2//!
3//! The cache a decoder keeps while generating is one key tensor and one value
4//! tensor per layer, per key-value head:
5//!
6//! ```text
7//! kv_bytes = 2 x bytes_per_element x num_hidden_layers x num_key_value_heads
8//!            x head_dim x context_length x batch_size
9//! ```
10//!
11//! Two terms in that product are the ones that go wrong in practice.
12//!
13//! The head count is [`KvCacheConfig::num_key_value_heads`], **not** the number of
14//! attention (query) heads. Grouped-query attention keeps an intermediate number of
15//! key-value heads — more than one, fewer than the query heads (Ainslie et al., *GQA:
16//! Training Generalized Multi-Query Transformer Checkpoints*, arXiv:2305.13245) — so
17//! substituting the query-head count overstates the cache by the whole GQA group size.
18//!
19//! The other is `head_dim`. Read it from the model config when the config publishes it.
20//! [`head_dim_from_hidden`] exists for the configs that genuinely omit it, and is kept
21//! off the main path so that the fallback is visible where it is used.
22//!
23//! This crate sizes the KV cache and nothing else — weights, activations, runtime
24//! context and allocator fragmentation are excluded, so treat the result as a floor.
25//! An interactive version with per-model configs is at
26//! <https://ml0x.com/calculators/kv-cache-size-calculator.html>.
27//!
28//! # Example
29//!
30//! ```
31//! use kv_cache_size::{KvCacheConfig, KvPrecision};
32//!
33//! // Llama 3.1 8B: 32 layers, 8 key-value heads, head_dim 128.
34//! let cfg = KvCacheConfig::new(32, 8, 128).unwrap();
35//! assert_eq!(cfg.bytes_per_token(KvPrecision::Bf16), 131_072.0);
36//! ```
37
38#![forbid(unsafe_code)]
39#![deny(missing_docs)]
40#![no_std]
41
42use core::fmt;
43
44/// Why a configuration or a query could not be evaluated.
45///
46/// Every variant is a refusal, never a silently approximated answer.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum KvError {
49    /// A structural field of the model config was zero.
50    ///
51    /// A transformer with no layers, no key-value heads or a zero head dimension is
52    /// not a model this formula describes.
53    ZeroDimension(&'static str),
54    /// `context_length` or `batch_size` was zero.
55    ZeroWorkload(&'static str),
56    /// The requested cache does not fit in a `u64` byte count.
57    Overflow,
58    /// A memory budget was negative or not finite.
59    InvalidBudget,
60}
61
62impl fmt::Display for KvError {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            KvError::ZeroDimension(field) => write!(f, "model config field `{field}` is zero"),
66            KvError::ZeroWorkload(field) => write!(f, "workload field `{field}` is zero"),
67            KvError::Overflow => f.write_str("KV cache size does not fit in u64 bytes"),
68            KvError::InvalidBudget => f.write_str("memory budget must be finite and non-negative"),
69        }
70    }
71}
72
73/// The element type the cache is stored in.
74///
75/// `Int4` is half a byte per element, which is why element size is a `f64` rather
76/// than an integer.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum KvPrecision {
79    /// `bf16` or `fp16`: two bytes per element. The default for most runtimes.
80    Bf16,
81    /// `fp8` KV cache: one byte per element.
82    Fp8,
83    /// `int4` KV cache: half a byte per element.
84    Int4,
85}
86
87impl KvPrecision {
88    /// Bytes occupied by one cached element.
89    #[must_use]
90    pub fn bytes_per_element(self) -> f64 {
91        match self {
92            KvPrecision::Bf16 => 2.0,
93            KvPrecision::Fp8 => 1.0,
94            KvPrecision::Int4 => 0.5,
95        }
96    }
97}
98
99/// The three model-config fields the KV-cache formula actually needs.
100///
101/// These map onto `num_hidden_layers`, `num_key_value_heads` and `head_dim` in a
102/// Hugging Face `config.json`.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct KvCacheConfig {
105    /// `num_hidden_layers` — every layer keeps its own K and V tensors.
106    pub num_hidden_layers: u32,
107    /// `num_key_value_heads` — the GQA key-value head count, not the query head count.
108    pub num_key_value_heads: u32,
109    /// `head_dim` — the per-head dimension of the key and value vectors.
110    pub head_dim: u32,
111}
112
113impl KvCacheConfig {
114    /// Builds a config, refusing any zero dimension.
115    ///
116    /// # Errors
117    ///
118    /// [`KvError::ZeroDimension`] naming the offending field.
119    pub fn new(
120        num_hidden_layers: u32,
121        num_key_value_heads: u32,
122        head_dim: u32,
123    ) -> Result<Self, KvError> {
124        if num_hidden_layers == 0 {
125            return Err(KvError::ZeroDimension("num_hidden_layers"));
126        }
127        if num_key_value_heads == 0 {
128            return Err(KvError::ZeroDimension("num_key_value_heads"));
129        }
130        if head_dim == 0 {
131            return Err(KvError::ZeroDimension("head_dim"));
132        }
133        Ok(Self {
134            num_hidden_layers,
135            num_key_value_heads,
136            head_dim,
137        })
138    }
139
140    /// Bytes the cache grows by for one additional token in one sequence.
141    ///
142    /// This is the number that decides whether cache size or weight size dominates,
143    /// and it is independent of context length and batch size.
144    #[must_use]
145    pub fn bytes_per_token(self, precision: KvPrecision) -> f64 {
146        2.0 * precision.bytes_per_element()
147            * f64::from(self.num_hidden_layers)
148            * f64::from(self.num_key_value_heads)
149            * f64::from(self.head_dim)
150    }
151
152    /// Total cache bytes for `context_length` tokens across `batch_size` sequences.
153    ///
154    /// # Errors
155    ///
156    /// [`KvError::ZeroWorkload`] if either argument is zero — a zero-token or
157    /// zero-sequence workload is a caller mistake, not a zero-byte answer.
158    pub fn total_bytes(
159        self,
160        precision: KvPrecision,
161        context_length: u64,
162        batch_size: u32,
163    ) -> Result<f64, KvError> {
164        if context_length == 0 {
165            return Err(KvError::ZeroWorkload("context_length"));
166        }
167        if batch_size == 0 {
168            return Err(KvError::ZeroWorkload("batch_size"));
169        }
170        Ok(self.bytes_per_token(precision) * context_length as f64 * f64::from(batch_size))
171    }
172
173    /// The same total, rounded down to whole bytes.
174    ///
175    /// # Errors
176    ///
177    /// As [`Self::total_bytes`], plus [`KvError::Overflow`] when the result exceeds
178    /// `u64::MAX` bytes.
179    pub fn total_bytes_u64(
180        self,
181        precision: KvPrecision,
182        context_length: u64,
183        batch_size: u32,
184    ) -> Result<u64, KvError> {
185        let bytes = self.total_bytes(precision, context_length, batch_size)?;
186        if !bytes.is_finite() || bytes >= u64::MAX as f64 {
187            return Err(KvError::Overflow);
188        }
189        Ok(bytes as u64)
190    }
191
192    /// The longest context that fits `budget_bytes`, at this precision and batch size.
193    ///
194    /// Rounds down: the returned length is guaranteed to fit. Returns `0` when not
195    /// even one token fits, which is a real answer and not an error.
196    ///
197    /// # Errors
198    ///
199    /// [`KvError::InvalidBudget`] for a negative or non-finite budget, and
200    /// [`KvError::ZeroWorkload`] for a zero batch size.
201    pub fn max_context(
202        self,
203        precision: KvPrecision,
204        budget_bytes: f64,
205        batch_size: u32,
206    ) -> Result<u64, KvError> {
207        if !budget_bytes.is_finite() || budget_bytes < 0.0 {
208            return Err(KvError::InvalidBudget);
209        }
210        if batch_size == 0 {
211            return Err(KvError::ZeroWorkload("batch_size"));
212        }
213        let per_token = self.bytes_per_token(precision) * f64::from(batch_size);
214        let tokens = budget_bytes / per_token;
215        if tokens >= u64::MAX as f64 {
216            return Err(KvError::Overflow);
217        }
218        // `floor` on a non-negative finite quotient: the result always fits.
219        Ok(tokens as u64)
220    }
221
222    /// The factor by which using `num_attention_heads` instead of
223    /// `num_key_value_heads` would overstate the cache.
224    ///
225    /// This is the GQA group size. It is `1.0` for a model without GQA, and the
226    /// full head count for multi-query attention. Returns `None` if
227    /// `num_attention_heads` is zero.
228    #[must_use]
229    pub fn gqa_overstatement(self, num_attention_heads: u32) -> Option<f64> {
230        if num_attention_heads == 0 {
231            return None;
232        }
233        Some(f64::from(num_attention_heads) / f64::from(self.num_key_value_heads))
234    }
235}
236
237/// Derives `head_dim` from `hidden_size / num_attention_heads`.
238///
239/// Use this **only** when the model config does not publish `head_dim`. When the
240/// config publishes it, the published value wins: the two disagree on real configs,
241/// and this function has no way to know that.
242///
243/// Returns `None` when `num_attention_heads` is zero or does not divide
244/// `hidden_size` exactly — a non-integer head dimension means the fallback does not
245/// describe this architecture, and guessing is worse than refusing.
246#[must_use]
247pub fn head_dim_from_hidden(hidden_size: u32, num_attention_heads: u32) -> Option<u32> {
248    if num_attention_heads == 0 || hidden_size == 0 {
249        return None;
250    }
251    if hidden_size % num_attention_heads != 0 {
252        return None;
253    }
254    Some(hidden_size / num_attention_heads)
255}
256
257/// Bytes in one gibibyte, for turning a VRAM figure into a budget.
258pub const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
259
260/// Bytes in one mebibyte.
261pub const MIB: f64 = 1024.0 * 1024.0;
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    /// Llama 3.1 8B: 32 layers, 8 KV heads, head_dim 128.
268    fn llama31_8b() -> KvCacheConfig {
269        KvCacheConfig::new(32, 8, 128).unwrap()
270    }
271
272    #[test]
273    fn llama31_8b_is_128_kib_per_token() {
274        // 2 * 2 * 32 * 8 * 128 = 131072 bytes = 128 KiB.
275        assert_eq!(llama31_8b().bytes_per_token(KvPrecision::Bf16), 131_072.0);
276    }
277
278    #[test]
279    fn qwen25_7b_is_56_kib_per_token() {
280        // 28 layers, 4 KV heads, head_dim 128 -> 2 * 2 * 28 * 4 * 128 = 57344.
281        let cfg = KvCacheConfig::new(28, 4, 128).unwrap();
282        assert_eq!(cfg.bytes_per_token(KvPrecision::Bf16), 57_344.0);
283    }
284
285    #[test]
286    fn precision_scales_linearly() {
287        let cfg = llama31_8b();
288        let bf16 = cfg.bytes_per_token(KvPrecision::Bf16);
289        assert_eq!(cfg.bytes_per_token(KvPrecision::Fp8), bf16 / 2.0);
290        assert_eq!(cfg.bytes_per_token(KvPrecision::Int4), bf16 / 4.0);
291    }
292
293    #[test]
294    fn eight_k_context_is_one_gib() {
295        let cfg = llama31_8b();
296        assert_eq!(cfg.total_bytes(KvPrecision::Bf16, 8192, 1).unwrap(), GIB);
297        assert_eq!(
298            cfg.total_bytes_u64(KvPrecision::Bf16, 8192, 1).unwrap(),
299            1_073_741_824
300        );
301    }
302
303    #[test]
304    fn batch_multiplies_the_total() {
305        let cfg = llama31_8b();
306        let one = cfg.total_bytes(KvPrecision::Bf16, 4096, 1).unwrap();
307        let four = cfg.total_bytes(KvPrecision::Bf16, 4096, 4).unwrap();
308        assert_eq!(four, one * 4.0);
309    }
310
311    #[test]
312    fn max_context_round_trips_against_total_bytes() {
313        let cfg = llama31_8b();
314        // 16 GiB at fp8, batch 4: 16 GiB / (65_536 bytes/token * 4) = 65_536 tokens.
315        let tokens = cfg.max_context(KvPrecision::Fp8, 16.0 * GIB, 4).unwrap();
316        assert_eq!(tokens, 65_536);
317        // The answer fits, and one more token does not.
318        assert!(cfg.total_bytes(KvPrecision::Fp8, tokens, 4).unwrap() <= 16.0 * GIB);
319        assert!(cfg.total_bytes(KvPrecision::Fp8, tokens + 1, 4).unwrap() > 16.0 * GIB);
320    }
321
322    #[test]
323    fn max_context_rounds_down_and_can_be_zero() {
324        let cfg = llama31_8b();
325        // Half a token's worth of budget fits no tokens at all.
326        let half = cfg.bytes_per_token(KvPrecision::Bf16) / 2.0;
327        assert_eq!(cfg.max_context(KvPrecision::Bf16, half, 1).unwrap(), 0);
328        // One and a half tokens' worth fits exactly one.
329        assert_eq!(cfg.max_context(KvPrecision::Bf16, half * 3.0, 1).unwrap(), 1);
330    }
331
332    #[test]
333    fn gqa_overstatement_is_the_group_size() {
334        // Llama 3.1 8B has 32 query heads against 8 KV heads.
335        assert_eq!(llama31_8b().gqa_overstatement(32), Some(4.0));
336        // No GQA: query heads equal KV heads.
337        let mha = KvCacheConfig::new(32, 32, 128).unwrap();
338        assert_eq!(mha.gqa_overstatement(32), Some(1.0));
339        assert_eq!(mha.gqa_overstatement(0), None);
340    }
341
342    #[test]
343    fn zero_dimensions_are_refused_by_name() {
344        assert_eq!(
345            KvCacheConfig::new(0, 8, 128),
346            Err(KvError::ZeroDimension("num_hidden_layers"))
347        );
348        assert_eq!(
349            KvCacheConfig::new(32, 0, 128),
350            Err(KvError::ZeroDimension("num_key_value_heads"))
351        );
352        assert_eq!(
353            KvCacheConfig::new(32, 8, 0),
354            Err(KvError::ZeroDimension("head_dim"))
355        );
356    }
357
358    #[test]
359    fn zero_workload_is_refused_not_zeroed() {
360        let cfg = llama31_8b();
361        assert_eq!(
362            cfg.total_bytes(KvPrecision::Bf16, 0, 1),
363            Err(KvError::ZeroWorkload("context_length"))
364        );
365        assert_eq!(
366            cfg.total_bytes(KvPrecision::Bf16, 1024, 0),
367            Err(KvError::ZeroWorkload("batch_size"))
368        );
369    }
370
371    #[test]
372    fn invalid_budgets_are_refused() {
373        let cfg = llama31_8b();
374        assert_eq!(
375            cfg.max_context(KvPrecision::Bf16, -1.0, 1),
376            Err(KvError::InvalidBudget)
377        );
378        assert_eq!(
379            cfg.max_context(KvPrecision::Bf16, f64::NAN, 1),
380            Err(KvError::InvalidBudget)
381        );
382        assert_eq!(
383            cfg.max_context(KvPrecision::Bf16, f64::INFINITY, 1),
384            Err(KvError::InvalidBudget)
385        );
386    }
387
388    #[test]
389    fn head_dim_fallback_refuses_non_integer_results() {
390        assert_eq!(head_dim_from_hidden(4096, 32), Some(128));
391        assert_eq!(head_dim_from_hidden(4096, 0), None);
392        assert_eq!(head_dim_from_hidden(0, 32), None);
393        // 4096 / 33 is not an integer: refuse rather than round.
394        assert_eq!(head_dim_from_hidden(4096, 33), None);
395    }
396
397    #[test]
398    fn overflow_is_reported_not_wrapped() {
399        let cfg = KvCacheConfig::new(u32::MAX, u32::MAX, u32::MAX).unwrap();
400        assert_eq!(
401            cfg.total_bytes_u64(KvPrecision::Bf16, u64::MAX, u32::MAX),
402            Err(KvError::Overflow)
403        );
404    }
405}