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}