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