Skip to main content

dynamo_mocker/common/
perf_model.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Performance model for timing simulations in the mocker.
5//!
6//! `Polynomial` remains a legacy configuration marker. Its implementation is
7//! owned by `aisimulate_core::engine`; this module only implements external NPZ and
8//! AI Configurator providers.
9
10use anyhow::{Context, Result};
11use ndarray::{Array1, Array2};
12use ndarray_interp::InterpolateError;
13use ndarray_interp::interp1d::{Interp1DBuilder, Linear};
14use ndarray_interp::interp2d::{Bilinear, Interp2DBuilder};
15use std::path::Path;
16use std::sync::Arc;
17
18/// Trait to abstract over 1D interpolation for prefill timing
19pub trait PrefillInterpolator: Send + Sync {
20    fn interp(&self, x: f64) -> Result<f64, InterpolateError>;
21}
22
23/// Trait to abstract over 2D interpolation for decode timing
24pub trait DecodeInterpolator: Send + Sync {
25    fn interp(&self, x: f64, y: f64) -> Result<f64, InterpolateError>;
26}
27
28/// Callback trait for direct AIC SDK calls.
29/// Implementors call the Rust AIC core API.
30pub trait AicCallback: Send + Sync {
31    /// Predict prefill latency in ms.
32    /// Parameters: (batch_size, effective_isl, prefix)
33    fn predict_prefill(
34        &self,
35        batch_size: usize,
36        effective_isl: usize,
37        prefix: usize,
38    ) -> Result<f64>;
39
40    /// Predict decode (generation) latency in ms.
41    /// Parameters: (batch_size, isl, osl)
42    fn predict_decode(&self, batch_size: usize, isl: usize, osl: usize) -> Result<f64>;
43}
44
45/// Wrapper to implement PrefillInterpolator for the concrete Interp1D type
46struct PrefillInterp1D {
47    inner: ndarray_interp::interp1d::Interp1D<
48        ndarray::OwnedRepr<f64>,
49        ndarray::OwnedRepr<f64>,
50        ndarray::Ix1,
51        Linear,
52    >,
53}
54
55impl PrefillInterpolator for PrefillInterp1D {
56    fn interp(&self, x: f64) -> Result<f64, InterpolateError> {
57        self.inner.interp_scalar(x)
58    }
59}
60
61/// Wrapper to implement DecodeInterpolator for the concrete Interp2D type
62struct DecodeInterp2D {
63    inner: ndarray_interp::interp2d::Interp2D<
64        ndarray::OwnedRepr<f64>,
65        ndarray::OwnedRepr<f64>,
66        ndarray::OwnedRepr<f64>,
67        ndarray::Ix2,
68        Bilinear,
69    >,
70}
71
72impl DecodeInterpolator for DecodeInterp2D {
73    fn interp(&self, x: f64, y: f64) -> Result<f64, InterpolateError> {
74        self.inner.interp_scalar(x, y)
75    }
76}
77
78/// Performance model for predicting prefill and decode timing
79#[derive(Default)]
80pub enum PerfModel {
81    /// Select the built-in AISimulate polynomial timing model.
82    #[default]
83    Polynomial,
84    /// Constant per-pass latencies owned by the AISimulate engine.
85    Fixed { prefill_ms: f64, decode_ms: f64 },
86    /// Interpolation-based model using profiler data
87    /// Decode axes: (scheduled logical KV tokens, mean context length)
88    Interpolated {
89        prefill_interp: Arc<dyn PrefillInterpolator>,
90        decode_interp: Arc<dyn DecodeInterpolator>,
91    },
92    /// AI Configurator SDK calls through the configured callback.
93    /// Passes the reduced prefill inputs (batch_size, effective_isl, prefix).
94    Aiconfigurator { callback: Arc<dyn AicCallback> },
95}
96
97impl Clone for PerfModel {
98    fn clone(&self) -> Self {
99        match self {
100            PerfModel::Polynomial => PerfModel::Polynomial,
101            PerfModel::Fixed {
102                prefill_ms,
103                decode_ms,
104            } => PerfModel::Fixed {
105                prefill_ms: *prefill_ms,
106                decode_ms: *decode_ms,
107            },
108            PerfModel::Interpolated {
109                prefill_interp,
110                decode_interp,
111            } => PerfModel::Interpolated {
112                prefill_interp: Arc::clone(prefill_interp),
113                decode_interp: Arc::clone(decode_interp),
114            },
115            PerfModel::Aiconfigurator { callback } => PerfModel::Aiconfigurator {
116                callback: Arc::clone(callback),
117            },
118        }
119    }
120}
121
122impl std::fmt::Debug for PerfModel {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        match self {
125            PerfModel::Polynomial => write!(f, "PerfModel::Polynomial"),
126            PerfModel::Fixed {
127                prefill_ms,
128                decode_ms,
129            } => write!(
130                f,
131                "PerfModel::Fixed {{ prefill_ms: {prefill_ms}, decode_ms: {decode_ms} }}"
132            ),
133            PerfModel::Interpolated { .. } => write!(f, "PerfModel::Interpolated {{ .. }}"),
134            PerfModel::Aiconfigurator { .. } => write!(f, "PerfModel::Aiconfigurator"),
135        }
136    }
137}
138
139impl PerfModel {
140    /// Load performance model from NPZ file
141    ///
142    /// Expected arrays in NPZ file:
143    /// - prefill_isl: 1D array of input sequence lengths
144    /// - prefill_ttft_ms: 1D array of time to first token in milliseconds
145    /// - decode_active_kv_tokens: 1D array of scheduled logical KV token counts
146    /// - decode_context_length: 1D array of context lengths
147    /// - decode_itl: 2D array of inter-token latencies in milliseconds
148    pub fn from_npz(path: &Path) -> Result<Self> {
149        use ndarray_npy::NpzReader;
150        use std::fs::File;
151
152        tracing::info!("Loading performance model from NPZ file: {:?}", path);
153
154        let file =
155            File::open(path).with_context(|| format!("Failed to open NPZ file: {:?}", path))?;
156
157        let mut npz = NpzReader::new(file)
158            .with_context(|| format!("Failed to create NPZ reader for: {:?}", path))?;
159
160        // Load prefill arrays
161        let prefill_isl: Array1<f64> = npz
162            .by_name("prefill_isl")
163            .with_context(|| "Failed to load prefill_isl from NPZ")?;
164        let prefill_ttft_ms: Array1<f64> = npz
165            .by_name("prefill_ttft_ms")
166            .with_context(|| "Failed to load prefill_ttft_ms from NPZ")?;
167
168        // Load decode arrays
169        let decode_active_kv_tokens: Array1<f64> = npz
170            .by_name("decode_active_kv_tokens")
171            .with_context(|| "Failed to load decode_active_kv_tokens from NPZ")?;
172        let decode_context_length: Array1<f64> = npz
173            .by_name("decode_context_length")
174            .with_context(|| "Failed to load decode_context_length from NPZ")?;
175        let decode_itl: Array2<f64> = npz
176            .by_name("decode_itl")
177            .with_context(|| "Failed to load decode_itl from NPZ")?;
178
179        // Validate dimensions
180        if prefill_isl.len() != prefill_ttft_ms.len() {
181            anyhow::bail!(
182                "Prefill array length mismatch: isl={}, ttft={}",
183                prefill_isl.len(),
184                prefill_ttft_ms.len()
185            );
186        }
187
188        if decode_itl.nrows() != decode_active_kv_tokens.len()
189            || decode_itl.ncols() != decode_context_length.len()
190        {
191            anyhow::bail!(
192                "Decode array dimension mismatch: itl shape=({}, {}), active_kv={}, context={}",
193                decode_itl.nrows(),
194                decode_itl.ncols(),
195                decode_active_kv_tokens.len(),
196                decode_context_length.len()
197            );
198        }
199
200        tracing::info!(
201            "Loaded performance model: prefill_points={}, decode_grid={}x{}",
202            prefill_isl.len(),
203            decode_itl.nrows(),
204            decode_itl.ncols()
205        );
206
207        // Build interpolators once during loading
208        let prefill_interp = Interp1DBuilder::new(prefill_ttft_ms)
209            .x(prefill_isl)
210            .strategy(Linear::new().extrapolate(true))
211            .build()
212            .with_context(|| "Failed to build prefill interpolator")?;
213
214        let decode_interp = Interp2DBuilder::new(decode_itl)
215            .x(decode_active_kv_tokens)
216            .y(decode_context_length)
217            .strategy(Bilinear::new().extrapolate(true))
218            .build()
219            .with_context(|| "Failed to build decode interpolator")?;
220
221        Ok(PerfModel::Interpolated {
222            prefill_interp: Arc::new(PrefillInterp1D {
223                inner: prefill_interp,
224            }),
225            decode_interp: Arc::new(DecodeInterp2D {
226                inner: decode_interp,
227            }),
228        })
229    }
230
231    /// Create an Aiconfigurator perf model from a callback.
232    pub fn from_aic_callback(callback: Arc<dyn AicCallback>) -> Self {
233        PerfModel::Aiconfigurator { callback }
234    }
235
236    /// Predict prefill time in milliseconds.
237    ///
238    /// Callers always pass all parameters; each external variant uses what it needs:
239    /// - Interpolated uses total new tokens across the batch
240    ///   (`batch_size * (isl - prefix)`).
241    /// - Aiconfigurator: passes (batch_size, isl - prefix, prefix) to the AIC SDK
242    pub fn predict_prefill_time(
243        &self,
244        batch_size: usize,
245        isl: usize,
246        prefix: usize,
247    ) -> Result<f64> {
248        if matches!(self, Self::Polynomial | Self::Fixed { .. }) {
249            anyhow::bail!("built-in timing is implemented by the AISimulate engine");
250        }
251        let new_tokens_per_req = isl.saturating_sub(prefix);
252        if batch_size == 0 || new_tokens_per_req == 0 {
253            return Ok(0.0);
254        }
255        let time = match self {
256            PerfModel::Polynomial => unreachable!("polynomial handled above"),
257            PerfModel::Fixed { .. } => unreachable!("fixed timing handled above"),
258            PerfModel::Interpolated { prefill_interp, .. } => {
259                let tokens = (batch_size * new_tokens_per_req) as f64;
260                prefill_interp.interp(tokens).unwrap_or(0.0)
261            }
262            PerfModel::Aiconfigurator { callback } => callback
263                .predict_prefill(batch_size, new_tokens_per_req, prefix)
264                .context("AIC prefill prediction failed")?,
265        };
266        Ok(time.max(0.0))
267    }
268
269    /// Predict decode time in milliseconds.
270    ///
271    /// `active_kv_tokens` is the sum of logical context lengths in the scheduled
272    /// batch, not the number of distinct physically resident tokens.
273    ///
274    /// Callers always pass all parameters; each variant uses what it needs:
275    /// - Interpolated: uses (active_kv_tokens, context_length)
276    /// - Aiconfigurator: uses (batch_size, context_length)
277    pub fn predict_decode_time(
278        &self,
279        batch_size: usize,
280        active_kv_tokens: usize,
281        context_length: usize,
282        _total_kv_tokens: usize,
283    ) -> Result<f64> {
284        if matches!(self, Self::Polynomial | Self::Fixed { .. }) {
285            anyhow::bail!("built-in timing is implemented by the AISimulate engine");
286        }
287        if batch_size == 0 {
288            return Ok(0.0);
289        }
290        let time = match self {
291            PerfModel::Polynomial => unreachable!("polynomial handled above"),
292            PerfModel::Fixed { .. } => unreachable!("fixed timing handled above"),
293            PerfModel::Interpolated { decode_interp, .. } => decode_interp
294                .interp(active_kv_tokens as f64, context_length as f64)
295                .unwrap_or(0.0),
296            PerfModel::Aiconfigurator { callback } => callback
297                .predict_decode(batch_size, context_length, 2)
298                .context("AIC decode prediction failed")?,
299        };
300        // Token-emitting decode steps should not collapse onto the same timestamp.
301        let result = time.max(1.0);
302        tracing::trace!(
303            "Decode time prediction: batch_size={batch_size}, active_kv_tokens={active_kv_tokens}, context_length={context_length}, time={result:.2}ms"
304        );
305        Ok(result)
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::{AicCallback, PerfModel};
312    use std::sync::Arc;
313
314    struct EchoBatchCallback;
315
316    impl AicCallback for EchoBatchCallback {
317        fn predict_prefill(
318            &self,
319            batch_size: usize,
320            _effective_isl: usize,
321            _prefix: usize,
322        ) -> anyhow::Result<f64> {
323            Ok(batch_size as f64)
324        }
325
326        fn predict_decode(
327            &self,
328            batch_size: usize,
329            _isl: usize,
330            _osl: usize,
331        ) -> anyhow::Result<f64> {
332            Ok(batch_size as f64)
333        }
334    }
335
336    struct FailingCallback;
337
338    impl AicCallback for FailingCallback {
339        fn predict_prefill(
340            &self,
341            _batch_size: usize,
342            _effective_isl: usize,
343            _prefix: usize,
344        ) -> anyhow::Result<f64> {
345            anyhow::bail!("missing AIC prefill point")
346        }
347
348        fn predict_decode(
349            &self,
350            _batch_size: usize,
351            _isl: usize,
352            _osl: usize,
353        ) -> anyhow::Result<f64> {
354            anyhow::bail!("missing AIC decode point")
355        }
356    }
357
358    #[test]
359    fn polynomial_is_only_a_builtin_engine_marker() {
360        let model = PerfModel::default();
361        assert!(matches!(model, PerfModel::Polynomial));
362        assert!(model.predict_prefill_time(1, 128, 0).is_err());
363        assert!(model.predict_decode_time(1, 128, 128, 1024).is_err());
364    }
365
366    #[test]
367    fn aic_forwards_scheduler_local_batch() {
368        let model = PerfModel::from_aic_callback(Arc::new(EchoBatchCallback));
369
370        assert_eq!(model.predict_prefill_time(7, 128, 0).unwrap(), 7.0);
371        assert_eq!(model.predict_decode_time(9, 0, 128, 0).unwrap(), 9.0);
372    }
373
374    #[test]
375    fn aic_prefill_prediction_errors_propagate() {
376        let error = PerfModel::from_aic_callback(Arc::new(FailingCallback))
377            .predict_prefill_time(2, 128, 32)
378            .unwrap_err();
379
380        assert_eq!(error.to_string(), "AIC prefill prediction failed");
381        assert_eq!(error.root_cause().to_string(), "missing AIC prefill point");
382    }
383
384    #[test]
385    fn aic_decode_prediction_errors_propagate() {
386        let error = PerfModel::from_aic_callback(Arc::new(FailingCallback))
387            .predict_decode_time(2, 64, 128, 1024)
388            .unwrap_err();
389
390        assert_eq!(error.to_string(), "AIC decode prediction failed");
391        assert_eq!(error.root_cause().to_string(), "missing AIC decode point");
392    }
393}