1#![forbid(unsafe_code)]
39#![deny(missing_docs)]
40#![no_std]
41
42use core::fmt;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum KvError {
49 ZeroDimension(&'static str),
54 ZeroWorkload(&'static str),
56 Overflow,
58 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum KvPrecision {
79 Bf16,
81 Fp8,
83 Int4,
85}
86
87impl KvPrecision {
88 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct KvCacheConfig {
105 pub num_hidden_layers: u32,
107 pub num_key_value_heads: u32,
109 pub head_dim: u32,
111}
112
113impl KvCacheConfig {
114 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 #[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 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 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 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 Ok(tokens as u64)
220 }
221
222 #[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#[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
257pub const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
259
260pub const MIB: f64 = 1024.0 * 1024.0;
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 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 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 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 let tokens = cfg.max_context(KvPrecision::Fp8, 16.0 * GIB, 4).unwrap();
316 assert_eq!(tokens, 65_536);
317 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 let half = cfg.bytes_per_token(KvPrecision::Bf16) / 2.0;
327 assert_eq!(cfg.max_context(KvPrecision::Bf16, half, 1).unwrap(), 0);
328 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 assert_eq!(llama31_8b().gqa_overstatement(32), Some(4.0));
336 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 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}