Skip to main content

aisimulate_core/perfmodel/
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;
36use crate::perfmodel::EngineConfig;
37
38/// KV-cache memory request. `engine` reuses the modularised [`EngineConfig`];
39/// the remaining fields describe the runtime sizing budget.
40#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
41pub struct KvCacheEstimateRequest {
42    pub engine: EngineConfig,
43    pub max_num_tokens: u32,
44    pub max_batch_size: u32,
45    pub kv_cache_memory_fraction: KvCacheMemoryFraction,
46    /// Override for unknown SKUs; when `Some`, it wins over the SystemSpec
47    /// capacity reported by the native path.
48    pub gpu_memory_capacity_bytes_override: Option<u64>,
49    /// `None` = raw estimate only; `Some(0.05)` = 5% safety margin.
50    pub tolerance_fraction: Option<f64>,
51    pub options: KvCacheEstimateOptions,
52}
53
54/// Backend-tagged memory fraction. The variant encodes the XOR between
55/// TRT-LLM's free-fraction and vLLM/SGLang's total-fraction semantics; the
56/// Python `estimate_kv_cache` validates it against `engine.backend` and returns
57/// an error (mapped to [`KvCacheEstimateError::IncompatibleMemoryFraction`]) if
58/// mismatched.
59#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
60pub enum KvCacheMemoryFraction {
61    /// Fraction of TOTAL GPU memory. Compatible with vLLM
62    /// (`gpu_memory_utilization`) and SGLang (`mem_fraction_static`).
63    OfTotal(f64),
64    /// Fraction of FREE (post-non-KV) GPU memory. Compatible with TRT-LLM
65    /// (`free_gpu_memory_fraction`).
66    OfFree(f64),
67}
68
69impl KvCacheMemoryFraction {
70    /// `(kind, value)` pair for the flat Python call. `kind` is the wire string
71    /// the Python `estimate_kv_cache` expects (`"of_total"` / `"of_free"`).
72    fn to_wire(self) -> (&'static str, f64) {
73        match self {
74            Self::OfTotal(f) => ("of_total", f),
75            Self::OfFree(f) => ("of_free", f),
76        }
77    }
78}
79
80/// Default for [`KvCacheEstimateOptions::naive_kv_reservation`] so a JSON request
81/// from an embedded caller (the Mocker) that omits this newer field still
82/// deserializes (to the same 0.80 the Python side defaults to).
83fn default_naive_kv_reservation() -> f64 {
84    0.80
85}
86
87#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
88pub struct KvCacheEstimateOptions {
89    pub allow_naive_fallback: bool,
90    pub allow_hf_config_download: bool,
91    /// Fraction of post-weight memory the naive fallback reserves for KV
92    /// (default `0.80`). Ignored on the native path. Exposed so the Mocker can
93    /// tune the crude fallback budget.
94    #[serde(default = "default_naive_kv_reservation")]
95    pub naive_kv_reservation: f64,
96}
97
98/// KV-cache memory estimate.
99#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
100pub struct KvCacheEstimate {
101    pub total_gpu_capacity_bytes: u64,
102    pub total_kv_size_bytes: u64,
103    pub kv_size_per_token_bytes: u64,
104    pub total_kv_size_tokens: u64,
105    pub source: EstimateSource,
106    /// `Some` on the native path; `None` on the naive fallback.
107    pub memory_breakdown: Option<MemoryBreakdown>,
108    /// `Some` iff `tolerance_fraction` set; `None` for the raw estimate.
109    pub tolerance_adjusted: Option<KvCacheEstimateAdjusted>,
110}
111
112#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
113pub enum EstimateSource {
114    /// AIC's full backend memory model used.
115    Native,
116    /// Post-weight reservation heuristic (`naive_kv_reservation`, default 80%).
117    NaiveFallback,
118}
119
120/// Non-KV memory components, in bytes. Maps AIC's `_get_memory_usage` dict:
121/// `weights → weights`, `activations → activations`, `others →
122/// runtime_overhead`, `nccl → comm_overhead`.
123#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
124pub struct MemoryBreakdown {
125    pub weights_bytes: u64,
126    pub activations_bytes: u64,
127    pub runtime_overhead_bytes: u64,
128    pub comm_overhead_bytes: u64,
129}
130
131#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
132pub struct KvCacheEstimateAdjusted {
133    pub tolerance_fraction: f64,
134    pub total_kv_size_bytes: u64,
135    pub total_kv_size_tokens: u64,
136}
137
138#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
139pub enum KvCacheEstimateError {
140    Unsupported {
141        model: String,
142        backend: BackendKind,
143        gpu_sku: String,
144        reason: String,
145    },
146    InsufficientModelMetadata {
147        missing_fields: Vec<String>,
148    },
149    NoKvBudget {
150        total_gpu_capacity_bytes: u64,
151        non_kv_bytes: u64,
152    },
153    IncompatibleMemoryFraction {
154        backend: BackendKind,
155        variant_kind: &'static str,
156    },
157    BadConfig {
158        field: String,
159        reason: String,
160    },
161    HfConfigFetchFailed {
162        hf_id: String,
163        source: String,
164    },
165}
166
167impl std::fmt::Display for KvCacheEstimateError {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        match self {
170            Self::Unsupported {
171                model,
172                backend,
173                gpu_sku,
174                reason,
175            } => write!(
176                f,
177                "unsupported model/backend/GPU for KV-cache estimation: model={model}, \
178                 backend={backend:?}, gpu_sku={gpu_sku}: {reason}"
179            ),
180            Self::InsufficientModelMetadata { missing_fields } => {
181                write!(
182                    f,
183                    "insufficient model metadata; missing: {missing_fields:?}"
184                )
185            }
186            Self::NoKvBudget {
187                total_gpu_capacity_bytes,
188                non_kv_bytes,
189            } => write!(
190                f,
191                "no KV budget: non-KV memory ({non_kv_bytes} bytes) meets/exceeds the \
192                 KV-cache memory limit (capacity={total_gpu_capacity_bytes} bytes)"
193            ),
194            Self::IncompatibleMemoryFraction {
195                backend,
196                variant_kind,
197            } => write!(
198                f,
199                "incompatible memory fraction: backend {backend:?} does not accept \
200                 KvCacheMemoryFraction::{variant_kind}"
201            ),
202            Self::BadConfig { field, reason } => {
203                write!(f, "bad memory config field {field:?}: {reason}")
204            }
205            Self::HfConfigFetchFailed { hf_id, source } => {
206                write!(f, "HF config fetch failed for {hf_id:?}: {source}")
207            }
208        }
209    }
210}
211
212impl std::error::Error for KvCacheEstimateError {}
213
214/// Estimate KV-cache memory (raw estimate + optional tolerance margin).
215///
216/// Pure forwarder: crosses into Python once to compute the COMPLETE estimate. The
217/// Python `aiconfigurator.sdk.memory.estimate_kv_cache` does the backend↔fraction
218/// and tolerance validation, the native AIC memory breakdown + budget math, the
219/// naive heuristic fallback, AND the tolerance margin (`tolerance_adjusted`); the
220/// Rust side rebuilds a [`KvCacheEstimate`] from the returned dict with no math
221/// of its own.
222///
223/// Errors from the Python side (unsupported model/backend, incompatible memory
224/// fraction, out-of-range tolerance, no KV budget, HF config fetch failure) cross
225/// the PyO3 boundary as a `ValueError` whose message carries the failure detail
226/// and are surfaced here as [`KvCacheEstimateError::Unsupported`] with that
227/// message.
228#[cfg(feature = "python")]
229pub fn estimate_kv_cache(
230    req: KvCacheEstimateRequest,
231) -> Result<KvCacheEstimate, KvCacheEstimateError> {
232    fetch_python_estimate(&req)
233}
234
235/// Cross into Python once to compute the complete estimate.
236///
237/// Mirrors the `AicEngineBuilder` → `compile_engine` crossing: `with_gil →
238/// import aiconfigurator.sdk.memory → call estimate_kv_cache(...) → extract
239/// the returned dict`. `tolerance_fraction` is forwarded; the Python fn applies
240/// the tolerance and returns `tolerance_adjusted` in the dict.
241#[cfg(feature = "python")]
242fn fetch_python_estimate(
243    req: &KvCacheEstimateRequest,
244) -> Result<KvCacheEstimate, KvCacheEstimateError> {
245    use pyo3::prelude::*;
246    use pyo3::types::PyDict;
247
248    let engine = &req.engine;
249    let parallel = &engine.parallel;
250    let quant = &engine.quantization;
251    let nextn = engine
252        .speculative
253        .as_ref()
254        .and_then(|s| s.nextn)
255        .unwrap_or(0);
256    let (fraction_kind, fraction_value) = req.kv_cache_memory_fraction.to_wire();
257
258    Python::with_gil(|py| -> PyResult<KvCacheEstimate> {
259        let engine_mod = py.import("aiconfigurator.sdk.memory")?;
260        let kwargs = PyDict::new(py);
261        kwargs.set_item("backend_version", engine.backend_version.as_deref())?;
262        kwargs.set_item("max_num_tokens", req.max_num_tokens)?;
263        kwargs.set_item("max_batch_size", req.max_batch_size)?;
264        kwargs.set_item("memory_fraction_kind", fraction_kind)?;
265        kwargs.set_item("memory_fraction_value", fraction_value)?;
266        kwargs.set_item("tp_size", parallel.tp_size)?;
267        kwargs.set_item("pp_size", parallel.pp_size)?;
268        kwargs.set_item("attention_dp_size", parallel.attention_dp_size.unwrap_or(1))?;
269        kwargs.set_item("moe_tp_size", parallel.moe_tp_size)?;
270        kwargs.set_item("moe_ep_size", parallel.moe_ep_size)?;
271        kwargs.set_item(
272            "gemm_quant_mode",
273            quant.weight_dtype.as_ref().map(dtype_str),
274        )?;
275        kwargs.set_item("moe_quant_mode", quant.moe_dtype.as_ref().map(dtype_str))?;
276        kwargs.set_item(
277            "kvcache_quant_mode",
278            quant.kv_cache_dtype.as_ref().map(dtype_str),
279        )?;
280        kwargs.set_item(
281            "fmha_quant_mode",
282            quant.activation_dtype.as_ref().map(dtype_str),
283        )?;
284        // `comm_quant_mode` is intentionally NOT forwarded: the comm/NCCL
285        // overhead comes from `system_spec` (`nccl_mem` / `other_mem`), not the
286        // comm quant mode, so it does not affect the non-KV breakdown.
287        kwargs.set_item("nextn", nextn)?;
288        kwargs.set_item(
289            "systems_path",
290            engine.systems_path.as_deref().and_then(|p| p.to_str()),
291        )?;
292        kwargs.set_item(
293            "gpu_memory_capacity_bytes_override",
294            req.gpu_memory_capacity_bytes_override,
295        )?;
296        kwargs.set_item("tolerance_fraction", req.tolerance_fraction)?;
297        kwargs.set_item("naive_kv_reservation", req.options.naive_kv_reservation)?;
298        kwargs.set_item("allow_naive_fallback", req.options.allow_naive_fallback)?;
299        kwargs.set_item(
300            "allow_hf_config_download",
301            req.options.allow_hf_config_download,
302        )?;
303
304        let out = engine_mod.call_method(
305            "estimate_kv_cache",
306            (
307                engine.model_name.as_str(),
308                engine.system_name.as_str(),
309                engine.backend.as_str(),
310            ),
311            Some(&kwargs),
312        )?;
313
314        estimate_from_dict(&out)
315    })
316    // PyErr → KvCacheEstimateError inline (keeps the error enum self-contained).
317    // The Python side raises a ValueError whose message already carries the
318    // specific failure detail; surface it as Unsupported so the (deferred)
319    // Mocker fallback decision still keys on the same variant it did before.
320    .map_err(|e| KvCacheEstimateError::Unsupported {
321        model: engine.model_name.clone(),
322        backend: engine.backend.clone(),
323        gpu_sku: engine.system_name.clone(),
324        reason: format!("estimate_kv_cache: {e}"),
325    })
326}
327
328/// Rebuild a [`KvCacheEstimate`] from the Python `estimate_kv_cache` dict.
329/// The dict carries every struct field (`total_*`, `kv_size_per_token_bytes`,
330/// `source`, `memory_breakdown`, and `tolerance_adjusted`); the Python fn applies
331/// the tolerance, so `tolerance_adjusted` is a nested dict iff
332/// `tolerance_fraction` was set, `None` otherwise.
333#[cfg(feature = "python")]
334fn estimate_from_dict(
335    out: &pyo3::Bound<'_, pyo3::types::PyAny>,
336) -> pyo3::PyResult<KvCacheEstimate> {
337    use pyo3::exceptions::PyValueError;
338    use pyo3::types::PyAnyMethods;
339
340    let u64_at = |k: &str| -> pyo3::PyResult<u64> { out.get_item(k)?.extract::<u64>() };
341
342    let source = match out.get_item("source")?.extract::<String>()?.as_str() {
343        "native" => EstimateSource::Native,
344        "naive_fallback" => EstimateSource::NaiveFallback,
345        other => {
346            return Err(PyValueError::new_err(format!(
347                "estimate_kv_cache returned unknown source {other:?}"
348            )));
349        }
350    };
351
352    let breakdown_item = out.get_item("memory_breakdown")?;
353    let memory_breakdown = if breakdown_item.is_none() {
354        None
355    } else {
356        let get = |k: &str| -> pyo3::PyResult<u64> { breakdown_item.get_item(k)?.extract::<u64>() };
357        Some(MemoryBreakdown {
358            weights_bytes: get("weights_bytes")?,
359            activations_bytes: get("activations_bytes")?,
360            runtime_overhead_bytes: get("runtime_overhead_bytes")?,
361            comm_overhead_bytes: get("comm_overhead_bytes")?,
362        })
363    };
364
365    // The Python fn applies the tolerance, so `tolerance_adjusted` is a nested
366    // dict iff `tolerance_fraction` was set (keys mirror the Python emit side:
367    // `tolerance_fraction` / `total_kv_size_bytes` / `total_kv_size_tokens`).
368    let adjusted_item = out.get_item("tolerance_adjusted")?;
369    let tolerance_adjusted = if adjusted_item.is_none() {
370        None
371    } else {
372        Some(KvCacheEstimateAdjusted {
373            tolerance_fraction: adjusted_item
374                .get_item("tolerance_fraction")?
375                .extract::<f64>()?,
376            total_kv_size_bytes: adjusted_item
377                .get_item("total_kv_size_bytes")?
378                .extract::<u64>()?,
379            total_kv_size_tokens: adjusted_item
380                .get_item("total_kv_size_tokens")?
381                .extract::<u64>()?,
382        })
383    };
384
385    Ok(KvCacheEstimate {
386        total_gpu_capacity_bytes: u64_at("total_gpu_capacity_bytes")?,
387        total_kv_size_bytes: u64_at("total_kv_size_bytes")?,
388        kv_size_per_token_bytes: u64_at("kv_size_per_token_bytes")?,
389        total_kv_size_tokens: u64_at("total_kv_size_tokens")?,
390        source,
391        memory_breakdown,
392        tolerance_adjusted,
393    })
394}
395
396/// Map a [`crate::DataType`] to the snake_case quant-mode string the Python
397/// `_build_model_config` accepts (the serde `rename` already produces these).
398#[cfg(feature = "python")]
399fn dtype_str(dt: &crate::DataType) -> &'static str {
400    use crate::DataType::*;
401    match dt {
402        Bfloat16 => "bfloat16",
403        Float16 => "float16",
404        Fp8 => "fp8",
405        Fp8Static => "fp8_static",
406        Fp8Block => "fp8_block",
407        Nvfp4 => "nvfp4",
408        Int8 => "int8",
409        Int4 => "int4",
410        W4afp8 => "w4afp8",
411        W4a16Mxfp4 => "w4a16_mxfp4",
412        W4a8Mxfp4Mxfp8 => "w4a8_mxfp4_mxfp8",
413        W4a8Mxfp4Mxfp8Trtllm => "w4a8_mxfp4_mxfp8_trtllm",
414        W4a16Mxfp4Cutlass => "w4a16_mxfp4_cutlass",
415        W4a16Nvfp4 => "w4a16_nvfp4",
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    // Tolerance validation + application and the native/naive budget math now
424    // live entirely in Python (`aiconfigurator.sdk.memory.estimate_kv_cache`),
425    // exercised by `tests/unit/sdk/test_memory_estimation.py` and the integration
426    // parity test. The Rust side is a pure forwarder; the only pure-Rust unit
427    // left here is the memory-fraction wire mapping. The dict round-trip
428    // (`fetch_python_estimate` → `estimate_from_dict`, including the
429    // `tolerance_adjusted` parse) is covered end-to-end by the Mocker consumer.
430
431    /// The memory fraction must cross to Python as the wire `(kind, value)` pair
432    /// the Python `estimate_kv_cache` expects.
433    #[test]
434    fn memory_fraction_to_wire() {
435        assert_eq!(
436            KvCacheMemoryFraction::OfFree(0.9).to_wire(),
437            ("of_free", 0.9)
438        );
439        assert_eq!(
440            KvCacheMemoryFraction::OfTotal(0.85).to_wire(),
441            ("of_total", 0.85)
442        );
443    }
444}