Skip to main content

aiconfigurator_core/
memory.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! KV-cache memory estimation.
5//!
6//! [`estimate_kv_cache`] is a top-level crate function (NOT a method on
7//! `AicEngine`): estimation runs once at startup, uses overlapping but not
8//! identical inputs to `AicEngineBuilder`, and is a separate concern from
9//! latency prediction. The Dynamo Mocker is the primary external consumer; it
10//! calls this once and derives `num_gpu_blocks_per_rank` from
11//! `total_kv_size_tokens`.
12//!
13//! ## Rust is a pure forwarder; the estimate is computed in Python
14//!
15//! This mirrors how `AicEngineBuilder` forwards to Python's `compile_engine`:
16//! ALL of the work -- fraction + tolerance validation, HF-config parsing, the
17//! AIC backend memory model, the OfFree/OfTotal budget math, the naive heuristic
18//! fallback, AND the tolerance margin -- lives in
19//! `aiconfigurator.sdk.memory.estimate_kv_cache`. The Rust side:
20//!
21//! 1. crosses into Python once (`with_gil → import → call estimate_kv_cache →
22//!    extract dict`), forwarding `tolerance_fraction` through;
23//! 2. rebuilds a [`KvCacheEstimate`] from that dict (including
24//!    `tolerance_adjusted`), with no math of its own.
25//!
26//! The two budget formulas (TRT-LLM free-fraction vs vLLM/SGLang total-fraction),
27//! the naive fallback, and the tolerance margin all live on the Python side; see
28//! the docstring of `aiconfigurator.sdk.memory.estimate_kv_cache`. The
29//! [`KvCacheMemoryFraction`] enum still encodes the backend↔fraction XOR so the
30//! request shape is unambiguous; the variant is validated against
31//! `engine.backend` in Python.
32
33use serde::{Deserialize, Serialize};
34
35use crate::{BackendKind, EngineConfig};
36
37/// KV-cache memory request. `engine` reuses the modularised [`EngineConfig`];
38/// the remaining fields describe the runtime sizing budget.
39#[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    /// Override for unknown SKUs; when `Some`, it wins over the SystemSpec
46    /// capacity reported by the native path.
47    pub gpu_memory_capacity_bytes_override: Option<u64>,
48    /// `None` = raw estimate only; `Some(0.05)` = 5% safety margin.
49    pub tolerance_fraction: Option<f64>,
50    pub options: KvCacheEstimateOptions,
51}
52
53/// Backend-tagged memory fraction. The variant encodes the XOR between
54/// TRT-LLM's free-fraction and vLLM/SGLang's total-fraction semantics; the
55/// Python `estimate_kv_cache` validates it against `engine.backend` and returns
56/// an error (mapped to [`KvCacheEstimateError::IncompatibleMemoryFraction`]) if
57/// mismatched.
58#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
59pub enum KvCacheMemoryFraction {
60    /// Fraction of TOTAL GPU memory. Compatible with vLLM
61    /// (`gpu_memory_utilization`) and SGLang (`mem_fraction_static`).
62    OfTotal(f64),
63    /// Fraction of FREE (post-non-KV) GPU memory. Compatible with TRT-LLM
64    /// (`free_gpu_memory_fraction`).
65    OfFree(f64),
66}
67
68impl KvCacheMemoryFraction {
69    /// `(kind, value)` pair for the flat Python call. `kind` is the wire string
70    /// the Python `estimate_kv_cache` expects (`"of_total"` / `"of_free"`).
71    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
79/// Default for [`KvCacheEstimateOptions::naive_kv_reservation`] so a JSON request
80/// from an embedded caller (the Mocker) that omits this newer field still
81/// deserializes (to the same 0.80 the Python side defaults to).
82fn 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    /// Fraction of post-weight memory the naive fallback reserves for KV
91    /// (default `0.80`). Ignored on the native path. Exposed so the Mocker can
92    /// tune the crude fallback budget.
93    #[serde(default = "default_naive_kv_reservation")]
94    pub naive_kv_reservation: f64,
95}
96
97/// KV-cache memory estimate.
98#[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    /// `Some` on the native path; `None` on the naive fallback.
106    pub memory_breakdown: Option<MemoryBreakdown>,
107    /// `Some` iff `tolerance_fraction` set; `None` for the raw estimate.
108    pub tolerance_adjusted: Option<KvCacheEstimateAdjusted>,
109}
110
111#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
112pub enum EstimateSource {
113    /// AIC's full backend memory model used.
114    Native,
115    /// Post-weight reservation heuristic (`naive_kv_reservation`, default 80%).
116    NaiveFallback,
117}
118
119/// Non-KV memory components, in bytes. Maps AIC's `_get_memory_usage` dict:
120/// `weights → weights`, `activations → activations`, `others →
121/// runtime_overhead`, `nccl → comm_overhead`.
122#[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
213/// Estimate KV-cache memory (raw estimate + optional tolerance margin).
214///
215/// Pure forwarder: crosses into Python once to compute the COMPLETE estimate. The
216/// Python `aiconfigurator.sdk.memory.estimate_kv_cache` does the backend↔fraction
217/// and tolerance validation, the native AIC memory breakdown + budget math, the
218/// naive heuristic fallback, AND the tolerance margin (`tolerance_adjusted`); the
219/// Rust side rebuilds a [`KvCacheEstimate`] from the returned dict with no math
220/// of its own.
221///
222/// Errors from the Python side (unsupported model/backend, incompatible memory
223/// fraction, out-of-range tolerance, no KV budget, HF config fetch failure) cross
224/// the PyO3 boundary as a `ValueError` whose message carries the failure detail
225/// and are surfaced here as [`KvCacheEstimateError::Unsupported`] with that
226/// message.
227pub fn estimate_kv_cache(
228    req: KvCacheEstimateRequest,
229) -> Result<KvCacheEstimate, KvCacheEstimateError> {
230    fetch_python_estimate(&req)
231}
232
233/// Cross into Python once to compute the complete estimate.
234///
235/// Mirrors the `AicEngineBuilder` → `compile_engine` crossing: `with_gil →
236/// import aiconfigurator.sdk.memory → call estimate_kv_cache(...) → extract
237/// the returned dict`. `tolerance_fraction` is forwarded; the Python fn applies
238/// the tolerance and returns `tolerance_adjusted` in the dict.
239fn 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        // `comm_quant_mode` is intentionally NOT forwarded: the comm/NCCL
282        // overhead comes from `system_spec` (`nccl_mem` / `other_mem`), not the
283        // comm quant mode, so it does not affect the non-KV breakdown.
284        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    // PyErr → KvCacheEstimateError inline (keeps the error enum self-contained).
314    // The Python side raises a ValueError whose message already carries the
315    // specific failure detail; surface it as Unsupported so the (deferred)
316    // Mocker fallback decision still keys on the same variant it did before.
317    .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
325/// Rebuild a [`KvCacheEstimate`] from the Python `estimate_kv_cache` dict.
326/// The dict carries every struct field (`total_*`, `kv_size_per_token_bytes`,
327/// `source`, `memory_breakdown`, and `tolerance_adjusted`); the Python fn applies
328/// the tolerance, so `tolerance_adjusted` is a nested dict iff
329/// `tolerance_fraction` was set, `None` otherwise.
330fn 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    // The Python fn applies the tolerance, so `tolerance_adjusted` is a nested
362    // dict iff `tolerance_fraction` was set (keys mirror the Python emit side:
363    // `tolerance_fraction` / `total_kv_size_bytes` / `total_kv_size_tokens`).
364    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
392/// Map a [`crate::DataType`] to the snake_case quant-mode string the Python
393/// `_build_model_config` accepts (the serde `rename` already produces these).
394fn 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    // Tolerance validation + application and the native/naive budget math now
419    // live entirely in Python (`aiconfigurator.sdk.memory.estimate_kv_cache`),
420    // exercised by `tests/unit/sdk/test_memory_estimation.py` and the integration
421    // parity test. The Rust side is a pure forwarder; the only pure-Rust unit
422    // left here is the memory-fraction wire mapping. The dict round-trip
423    // (`fetch_python_estimate` → `estimate_from_dict`, including the
424    // `tolerance_adjusted` parse) is covered end-to-end by the Mocker consumer.
425
426    /// The memory fraction must cross to Python as the wire `(kind, value)` pair
427    /// the Python `estimate_kv_cache` expects.
428    #[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}