1use 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
18pub trait PrefillInterpolator: Send + Sync {
20 fn interp(&self, x: f64) -> Result<f64, InterpolateError>;
21}
22
23pub trait DecodeInterpolator: Send + Sync {
25 fn interp(&self, x: f64, y: f64) -> Result<f64, InterpolateError>;
26}
27
28pub trait AicCallback: Send + Sync {
31 fn predict_prefill(
34 &self,
35 batch_size: usize,
36 effective_isl: usize,
37 prefix: usize,
38 ) -> Result<f64>;
39
40 fn predict_decode(&self, batch_size: usize, isl: usize, osl: usize) -> Result<f64>;
43}
44
45struct 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
61struct 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#[derive(Default)]
80pub enum PerfModel {
81 #[default]
83 Polynomial,
84 Interpolated {
87 prefill_interp: Arc<dyn PrefillInterpolator>,
88 decode_interp: Arc<dyn DecodeInterpolator>,
89 },
90 Aiconfigurator { callback: Arc<dyn AicCallback> },
93}
94
95impl Clone for PerfModel {
96 fn clone(&self) -> Self {
97 match self {
98 PerfModel::Polynomial => PerfModel::Polynomial,
99 PerfModel::Interpolated {
100 prefill_interp,
101 decode_interp,
102 } => PerfModel::Interpolated {
103 prefill_interp: Arc::clone(prefill_interp),
104 decode_interp: Arc::clone(decode_interp),
105 },
106 PerfModel::Aiconfigurator { callback } => PerfModel::Aiconfigurator {
107 callback: Arc::clone(callback),
108 },
109 }
110 }
111}
112
113impl std::fmt::Debug for PerfModel {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 match self {
116 PerfModel::Polynomial => write!(f, "PerfModel::Polynomial"),
117 PerfModel::Interpolated { .. } => write!(f, "PerfModel::Interpolated {{ .. }}"),
118 PerfModel::Aiconfigurator { .. } => write!(f, "PerfModel::Aiconfigurator"),
119 }
120 }
121}
122
123impl PerfModel {
124 pub fn from_npz(path: &Path) -> Result<Self> {
133 use ndarray_npy::NpzReader;
134 use std::fs::File;
135
136 tracing::info!("Loading performance model from NPZ file: {:?}", path);
137
138 let file =
139 File::open(path).with_context(|| format!("Failed to open NPZ file: {:?}", path))?;
140
141 let mut npz = NpzReader::new(file)
142 .with_context(|| format!("Failed to create NPZ reader for: {:?}", path))?;
143
144 let prefill_isl: Array1<f64> = npz
146 .by_name("prefill_isl")
147 .with_context(|| "Failed to load prefill_isl from NPZ")?;
148 let prefill_ttft_ms: Array1<f64> = npz
149 .by_name("prefill_ttft_ms")
150 .with_context(|| "Failed to load prefill_ttft_ms from NPZ")?;
151
152 let decode_active_kv_tokens: Array1<f64> = npz
154 .by_name("decode_active_kv_tokens")
155 .with_context(|| "Failed to load decode_active_kv_tokens from NPZ")?;
156 let decode_context_length: Array1<f64> = npz
157 .by_name("decode_context_length")
158 .with_context(|| "Failed to load decode_context_length from NPZ")?;
159 let decode_itl: Array2<f64> = npz
160 .by_name("decode_itl")
161 .with_context(|| "Failed to load decode_itl from NPZ")?;
162
163 if prefill_isl.len() != prefill_ttft_ms.len() {
165 anyhow::bail!(
166 "Prefill array length mismatch: isl={}, ttft={}",
167 prefill_isl.len(),
168 prefill_ttft_ms.len()
169 );
170 }
171
172 if decode_itl.nrows() != decode_active_kv_tokens.len()
173 || decode_itl.ncols() != decode_context_length.len()
174 {
175 anyhow::bail!(
176 "Decode array dimension mismatch: itl shape=({}, {}), active_kv={}, context={}",
177 decode_itl.nrows(),
178 decode_itl.ncols(),
179 decode_active_kv_tokens.len(),
180 decode_context_length.len()
181 );
182 }
183
184 tracing::info!(
185 "Loaded performance model: prefill_points={}, decode_grid={}x{}",
186 prefill_isl.len(),
187 decode_itl.nrows(),
188 decode_itl.ncols()
189 );
190
191 let prefill_interp = Interp1DBuilder::new(prefill_ttft_ms)
193 .x(prefill_isl)
194 .strategy(Linear::new().extrapolate(true))
195 .build()
196 .with_context(|| "Failed to build prefill interpolator")?;
197
198 let decode_interp = Interp2DBuilder::new(decode_itl)
199 .x(decode_active_kv_tokens)
200 .y(decode_context_length)
201 .strategy(Bilinear::new().extrapolate(true))
202 .build()
203 .with_context(|| "Failed to build decode interpolator")?;
204
205 Ok(PerfModel::Interpolated {
206 prefill_interp: Arc::new(PrefillInterp1D {
207 inner: prefill_interp,
208 }),
209 decode_interp: Arc::new(DecodeInterp2D {
210 inner: decode_interp,
211 }),
212 })
213 }
214
215 pub fn from_aic_callback(callback: Arc<dyn AicCallback>) -> Self {
217 PerfModel::Aiconfigurator { callback }
218 }
219
220 pub fn predict_prefill_time(
227 &self,
228 batch_size: usize,
229 isl: usize,
230 prefix: usize,
231 ) -> Result<f64> {
232 let new_tokens_per_req = isl.saturating_sub(prefix);
233 if batch_size == 0 || new_tokens_per_req == 0 {
234 return Ok(0.0);
235 }
236 let time = match self {
237 PerfModel::Polynomial => polynomial_prefill_time(batch_size, new_tokens_per_req),
238 PerfModel::Interpolated { prefill_interp, .. } => {
239 let tokens = (batch_size * new_tokens_per_req) as f64;
240 prefill_interp.interp(tokens).unwrap_or(0.0)
241 }
242 PerfModel::Aiconfigurator { callback } => callback
243 .predict_prefill(batch_size, new_tokens_per_req, prefix)
244 .context("AIC prefill prediction failed")?,
245 };
246 Ok(time.max(0.0))
247 }
248
249 pub fn predict_decode_time(
256 &self,
257 batch_size: usize,
258 active_kv_tokens: usize,
259 context_length: usize,
260 total_kv_tokens: usize,
261 ) -> Result<f64> {
262 if batch_size == 0 {
263 return Ok(0.0);
264 }
265 let time = match self {
266 PerfModel::Polynomial => polynomial_decode_time(active_kv_tokens, total_kv_tokens),
267 PerfModel::Interpolated { decode_interp, .. } => decode_interp
268 .interp(active_kv_tokens as f64, context_length as f64)
269 .unwrap_or(0.0),
270 PerfModel::Aiconfigurator { callback } => callback
271 .predict_decode(batch_size, context_length, 2)
272 .context("AIC decode prediction failed")?,
273 };
274 let result = time.max(1.0);
276 tracing::trace!(
277 "Decode time prediction: batch_size={batch_size}, active_kv_tokens={active_kv_tokens}, context_length={context_length}, time={result:.2}ms"
278 );
279 Ok(result)
280 }
281}
282
283fn polynomial_prefill_time(batch_size: usize, new_tokens_per_request: usize) -> f64 {
284 let tokens = (batch_size * new_tokens_per_request) as f64;
286 4.209989e-07 * tokens.powi(2) + 1.518344e-02 * tokens + 1.650142e+01
287}
288
289fn polynomial_decode_time(active_kv_tokens: usize, total_kv_tokens: usize) -> f64 {
290 let active_perc = if total_kv_tokens > 0 {
291 active_kv_tokens as f64 / total_kv_tokens as f64
292 } else {
293 tracing::warn!("Total KV tokens is 0, using 1.0 as capacity");
294 1.0
295 };
296 -25.74 * active_perc.powi(2) + 54.01 * active_perc + 5.74
297}
298
299#[cfg(test)]
300mod tests {
301 use super::{AicCallback, PerfModel};
302 use std::sync::Arc;
303
304 struct EchoBatchCallback;
305
306 impl AicCallback for EchoBatchCallback {
307 fn predict_prefill(
308 &self,
309 batch_size: usize,
310 _effective_isl: usize,
311 _prefix: usize,
312 ) -> anyhow::Result<f64> {
313 Ok(batch_size as f64)
314 }
315
316 fn predict_decode(
317 &self,
318 batch_size: usize,
319 _isl: usize,
320 _osl: usize,
321 ) -> anyhow::Result<f64> {
322 Ok(batch_size as f64)
323 }
324 }
325
326 struct FailingCallback;
327
328 impl AicCallback for FailingCallback {
329 fn predict_prefill(
330 &self,
331 _batch_size: usize,
332 _effective_isl: usize,
333 _prefix: usize,
334 ) -> anyhow::Result<f64> {
335 anyhow::bail!("missing AIC prefill point")
336 }
337
338 fn predict_decode(
339 &self,
340 _batch_size: usize,
341 _isl: usize,
342 _osl: usize,
343 ) -> anyhow::Result<f64> {
344 anyhow::bail!("missing AIC decode point")
345 }
346 }
347
348 #[test]
349 fn fully_cached_prompt_skips_prefill() {
350 assert_eq!(
351 PerfModel::default()
352 .predict_prefill_time(1, 128, 128)
353 .unwrap(),
354 0.0
355 );
356 }
357
358 #[test]
359 fn aic_forwards_scheduler_local_batch() {
360 let model = PerfModel::from_aic_callback(Arc::new(EchoBatchCallback));
361
362 assert_eq!(model.predict_prefill_time(7, 128, 0).unwrap(), 7.0);
363 assert_eq!(model.predict_decode_time(9, 0, 128, 0).unwrap(), 9.0);
364 }
365
366 #[test]
367 fn aic_prefill_prediction_errors_propagate() {
368 let error = PerfModel::from_aic_callback(Arc::new(FailingCallback))
369 .predict_prefill_time(2, 128, 32)
370 .unwrap_err();
371
372 assert_eq!(error.to_string(), "AIC prefill prediction failed");
373 assert_eq!(error.root_cause().to_string(), "missing AIC prefill point");
374 }
375
376 #[test]
377 fn aic_decode_prediction_errors_propagate() {
378 let error = PerfModel::from_aic_callback(Arc::new(FailingCallback))
379 .predict_decode_time(2, 64, 128, 1024)
380 .unwrap_err();
381
382 assert_eq!(error.to_string(), "AIC decode prediction failed");
383 assert_eq!(error.root_cause().to_string(), "missing AIC decode point");
384 }
385}