1use serde::{Deserialize, Serialize};
34
35use crate::{BackendKind, EngineConfig};
36
37#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
40pub struct KvCacheEstimateRequest {
41 pub engine: EngineConfig,
42 pub max_num_tokens: u32,
43 pub max_batch_size: u32,
44 pub kv_cache_memory_fraction: KvCacheMemoryFraction,
45 pub gpu_memory_capacity_bytes_override: Option<u64>,
48 pub tolerance_fraction: Option<f64>,
50 pub options: KvCacheEstimateOptions,
51}
52
53#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
59pub enum KvCacheMemoryFraction {
60 OfTotal(f64),
63 OfFree(f64),
66}
67
68impl KvCacheMemoryFraction {
69 fn to_wire(self) -> (&'static str, f64) {
72 match self {
73 Self::OfTotal(f) => ("of_total", f),
74 Self::OfFree(f) => ("of_free", f),
75 }
76 }
77}
78
79fn default_naive_kv_reservation() -> f64 {
83 0.80
84}
85
86#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
87pub struct KvCacheEstimateOptions {
88 pub allow_naive_fallback: bool,
89 pub allow_hf_config_download: bool,
90 #[serde(default = "default_naive_kv_reservation")]
94 pub naive_kv_reservation: f64,
95}
96
97#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
99pub struct KvCacheEstimate {
100 pub total_gpu_capacity_bytes: u64,
101 pub total_kv_size_bytes: u64,
102 pub kv_size_per_token_bytes: u64,
103 pub total_kv_size_tokens: u64,
104 pub source: EstimateSource,
105 pub memory_breakdown: Option<MemoryBreakdown>,
107 pub tolerance_adjusted: Option<KvCacheEstimateAdjusted>,
109}
110
111#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
112pub enum EstimateSource {
113 Native,
115 NaiveFallback,
117}
118
119#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
123pub struct MemoryBreakdown {
124 pub weights_bytes: u64,
125 pub activations_bytes: u64,
126 pub runtime_overhead_bytes: u64,
127 pub comm_overhead_bytes: u64,
128}
129
130#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
131pub struct KvCacheEstimateAdjusted {
132 pub tolerance_fraction: f64,
133 pub total_kv_size_bytes: u64,
134 pub total_kv_size_tokens: u64,
135}
136
137#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
138pub enum KvCacheEstimateError {
139 Unsupported {
140 model: String,
141 backend: BackendKind,
142 gpu_sku: String,
143 reason: String,
144 },
145 InsufficientModelMetadata {
146 missing_fields: Vec<String>,
147 },
148 NoKvBudget {
149 total_gpu_capacity_bytes: u64,
150 non_kv_bytes: u64,
151 },
152 IncompatibleMemoryFraction {
153 backend: BackendKind,
154 variant_kind: &'static str,
155 },
156 BadConfig {
157 field: String,
158 reason: String,
159 },
160 HfConfigFetchFailed {
161 hf_id: String,
162 source: String,
163 },
164}
165
166impl std::fmt::Display for KvCacheEstimateError {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 match self {
169 Self::Unsupported {
170 model,
171 backend,
172 gpu_sku,
173 reason,
174 } => write!(
175 f,
176 "unsupported model/backend/GPU for KV-cache estimation: model={model}, \
177 backend={backend:?}, gpu_sku={gpu_sku}: {reason}"
178 ),
179 Self::InsufficientModelMetadata { missing_fields } => {
180 write!(
181 f,
182 "insufficient model metadata; missing: {missing_fields:?}"
183 )
184 }
185 Self::NoKvBudget {
186 total_gpu_capacity_bytes,
187 non_kv_bytes,
188 } => write!(
189 f,
190 "no KV budget: non-KV memory ({non_kv_bytes} bytes) meets/exceeds the \
191 KV-cache memory limit (capacity={total_gpu_capacity_bytes} bytes)"
192 ),
193 Self::IncompatibleMemoryFraction {
194 backend,
195 variant_kind,
196 } => write!(
197 f,
198 "incompatible memory fraction: backend {backend:?} does not accept \
199 KvCacheMemoryFraction::{variant_kind}"
200 ),
201 Self::BadConfig { field, reason } => {
202 write!(f, "bad memory config field {field:?}: {reason}")
203 }
204 Self::HfConfigFetchFailed { hf_id, source } => {
205 write!(f, "HF config fetch failed for {hf_id:?}: {source}")
206 }
207 }
208 }
209}
210
211impl std::error::Error for KvCacheEstimateError {}
212
213pub fn estimate_kv_cache(
228 req: KvCacheEstimateRequest,
229) -> Result<KvCacheEstimate, KvCacheEstimateError> {
230 fetch_python_estimate(&req)
231}
232
233fn fetch_python_estimate(
240 req: &KvCacheEstimateRequest,
241) -> Result<KvCacheEstimate, KvCacheEstimateError> {
242 use pyo3::prelude::*;
243 use pyo3::types::PyDict;
244
245 let engine = &req.engine;
246 let parallel = &engine.parallel;
247 let quant = &engine.quantization;
248 let nextn = engine
249 .speculative
250 .as_ref()
251 .and_then(|s| s.nextn)
252 .unwrap_or(0);
253 let (fraction_kind, fraction_value) = req.kv_cache_memory_fraction.to_wire();
254
255 Python::with_gil(|py| -> PyResult<KvCacheEstimate> {
256 let engine_mod = py.import("aiconfigurator.sdk.memory")?;
257 let kwargs = PyDict::new(py);
258 kwargs.set_item("backend_version", engine.backend_version.as_deref())?;
259 kwargs.set_item("max_num_tokens", req.max_num_tokens)?;
260 kwargs.set_item("max_batch_size", req.max_batch_size)?;
261 kwargs.set_item("memory_fraction_kind", fraction_kind)?;
262 kwargs.set_item("memory_fraction_value", fraction_value)?;
263 kwargs.set_item("tp_size", parallel.tp_size)?;
264 kwargs.set_item("pp_size", parallel.pp_size)?;
265 kwargs.set_item("attention_dp_size", parallel.attention_dp_size.unwrap_or(1))?;
266 kwargs.set_item("moe_tp_size", parallel.moe_tp_size)?;
267 kwargs.set_item("moe_ep_size", parallel.moe_ep_size)?;
268 kwargs.set_item(
269 "gemm_quant_mode",
270 quant.weight_dtype.as_ref().map(dtype_str),
271 )?;
272 kwargs.set_item("moe_quant_mode", quant.moe_dtype.as_ref().map(dtype_str))?;
273 kwargs.set_item(
274 "kvcache_quant_mode",
275 quant.kv_cache_dtype.as_ref().map(dtype_str),
276 )?;
277 kwargs.set_item(
278 "fmha_quant_mode",
279 quant.activation_dtype.as_ref().map(dtype_str),
280 )?;
281 kwargs.set_item("nextn", nextn)?;
285 kwargs.set_item(
286 "systems_path",
287 engine.systems_path.as_deref().and_then(|p| p.to_str()),
288 )?;
289 kwargs.set_item(
290 "gpu_memory_capacity_bytes_override",
291 req.gpu_memory_capacity_bytes_override,
292 )?;
293 kwargs.set_item("tolerance_fraction", req.tolerance_fraction)?;
294 kwargs.set_item("naive_kv_reservation", req.options.naive_kv_reservation)?;
295 kwargs.set_item("allow_naive_fallback", req.options.allow_naive_fallback)?;
296 kwargs.set_item(
297 "allow_hf_config_download",
298 req.options.allow_hf_config_download,
299 )?;
300
301 let out = engine_mod.call_method(
302 "estimate_kv_cache",
303 (
304 engine.model_name.as_str(),
305 engine.system_name.as_str(),
306 engine.backend.as_str(),
307 ),
308 Some(&kwargs),
309 )?;
310
311 estimate_from_dict(&out)
312 })
313 .map_err(|e| KvCacheEstimateError::Unsupported {
318 model: engine.model_name.clone(),
319 backend: engine.backend.clone(),
320 gpu_sku: engine.system_name.clone(),
321 reason: format!("estimate_kv_cache: {e}"),
322 })
323}
324
325fn estimate_from_dict(
331 out: &pyo3::Bound<'_, pyo3::types::PyAny>,
332) -> pyo3::PyResult<KvCacheEstimate> {
333 use pyo3::exceptions::PyValueError;
334 use pyo3::types::PyAnyMethods;
335
336 let u64_at = |k: &str| -> pyo3::PyResult<u64> { out.get_item(k)?.extract::<u64>() };
337
338 let source = match out.get_item("source")?.extract::<String>()?.as_str() {
339 "native" => EstimateSource::Native,
340 "naive_fallback" => EstimateSource::NaiveFallback,
341 other => {
342 return Err(PyValueError::new_err(format!(
343 "estimate_kv_cache returned unknown source {other:?}"
344 )))
345 }
346 };
347
348 let breakdown_item = out.get_item("memory_breakdown")?;
349 let memory_breakdown = if breakdown_item.is_none() {
350 None
351 } else {
352 let get = |k: &str| -> pyo3::PyResult<u64> { breakdown_item.get_item(k)?.extract::<u64>() };
353 Some(MemoryBreakdown {
354 weights_bytes: get("weights_bytes")?,
355 activations_bytes: get("activations_bytes")?,
356 runtime_overhead_bytes: get("runtime_overhead_bytes")?,
357 comm_overhead_bytes: get("comm_overhead_bytes")?,
358 })
359 };
360
361 let adjusted_item = out.get_item("tolerance_adjusted")?;
365 let tolerance_adjusted = if adjusted_item.is_none() {
366 None
367 } else {
368 Some(KvCacheEstimateAdjusted {
369 tolerance_fraction: adjusted_item
370 .get_item("tolerance_fraction")?
371 .extract::<f64>()?,
372 total_kv_size_bytes: adjusted_item
373 .get_item("total_kv_size_bytes")?
374 .extract::<u64>()?,
375 total_kv_size_tokens: adjusted_item
376 .get_item("total_kv_size_tokens")?
377 .extract::<u64>()?,
378 })
379 };
380
381 Ok(KvCacheEstimate {
382 total_gpu_capacity_bytes: u64_at("total_gpu_capacity_bytes")?,
383 total_kv_size_bytes: u64_at("total_kv_size_bytes")?,
384 kv_size_per_token_bytes: u64_at("kv_size_per_token_bytes")?,
385 total_kv_size_tokens: u64_at("total_kv_size_tokens")?,
386 source,
387 memory_breakdown,
388 tolerance_adjusted,
389 })
390}
391
392fn dtype_str(dt: &crate::DataType) -> &'static str {
395 use crate::DataType::*;
396 match dt {
397 Bfloat16 => "bfloat16",
398 Float16 => "float16",
399 Fp8 => "fp8",
400 Fp8Static => "fp8_static",
401 Fp8Block => "fp8_block",
402 Nvfp4 => "nvfp4",
403 Int8 => "int8",
404 Int4 => "int4",
405 W4afp8 => "w4afp8",
406 W4a16Mxfp4 => "w4a16_mxfp4",
407 W4a8Mxfp4Mxfp8 => "w4a8_mxfp4_mxfp8",
408 W4a8Mxfp4Mxfp8Trtllm => "w4a8_mxfp4_mxfp8_trtllm",
409 W4a16Mxfp4Cutlass => "w4a16_mxfp4_cutlass",
410 W4a16Nvfp4 => "w4a16_nvfp4",
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
429 fn memory_fraction_to_wire() {
430 assert_eq!(
431 KvCacheMemoryFraction::OfFree(0.9).to_wire(),
432 ("of_free", 0.9)
433 );
434 assert_eq!(
435 KvCacheMemoryFraction::OfTotal(0.85).to_wire(),
436 ("of_total", 0.85)
437 );
438 }
439}