Skip to main content

trueno/tuner/models/
throughput.rs

1//! Throughput regressor model for ML tuner.
2
3use serde::{Deserialize, Serialize};
4
5use super::super::error::TunerError;
6use super::super::features::TunerFeatures;
7use super::ThroughputPrediction;
8
9/// Simple linear regression model for throughput prediction.
10///
11/// Uses closed-form solution: w = (X^T X)^-1 X^T y. The `ml-tuner` showcase
12/// (SHOWCASE-BRICK-001) feeds these features into `aprender::RandomForestRegressor`
13/// from `examples/ml_tuner_demo.rs` (aprender is a dev-dependency — the SIMD foundation
14/// must not depend on the ML layer).
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ThroughputRegressor {
17    /// Model weights (one per feature + bias)
18    pub(crate) weights: Vec<f32>,
19    /// Feature importance scores
20    pub(crate) feature_importance: Vec<(String, f32)>,
21    /// Training sample count
22    pub(crate) sample_count: usize,
23    /// Mean absolute percentage error on validation
24    pub(crate) mape: f32,
25}
26
27impl Default for ThroughputRegressor {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl ThroughputRegressor {
34    /// Create a new regressor with default weights
35    pub fn new() -> Self {
36        // Initialize with heuristic-based weights
37        // These encode domain knowledge from SHOWCASE-BRICK-001
38        let mut weights = vec![0.0; TunerFeatures::DIM + 1]; // +1 for bias
39
40        // Bias: baseline throughput ~200 tok/s normalized
41        weights[0] = 0.4;
42
43        // Batch size has largest positive impact (index 6)
44        weights[7] = 0.3; // batch_size_norm
45
46        // CUDA graphs help (index 8)
47        weights[9] = 0.1; // cuda_graphs
48
49        // GPU memory bandwidth matters (index 35)
50        weights[36] = 0.15; // gpu_mem_bw_norm
51
52        // GPU SM count matters (index 37)
53        weights[38] = 0.1; // gpu_sm_norm
54
55        // Larger models are slower (negative impact)
56        weights[1] = -0.15; // model_params_b
57
58        // Longer sequences slower for decode
59        weights[8] = -0.05; // seq_len_log
60
61        Self {
62            weights,
63            feature_importance: Self::default_feature_importance(),
64            sample_count: 0,
65            mape: 0.15, // 15% default MAPE
66        }
67    }
68
69    fn default_feature_importance() -> Vec<(String, f32)> {
70        vec![
71            ("batch_size".into(), 0.25),
72            ("gpu_mem_bw".into(), 0.20),
73            ("model_params".into(), 0.15),
74            ("cuda_graphs".into(), 0.10),
75            ("gpu_sm_count".into(), 0.10),
76            ("hidden_dim".into(), 0.08),
77            ("quant_type".into(), 0.07),
78            ("seq_len".into(), 0.05),
79        ]
80    }
81
82    /// Train the model on labeled data
83    pub fn train(&mut self, data: &[(TunerFeatures, f32)]) -> Result<(), TunerError> {
84        if data.len() < 10 {
85            return Err(TunerError::InsufficientData(data.len()));
86        }
87
88        // Simple gradient descent (in production: aprender's GBDT)
89        let learning_rate = 0.01;
90        let epochs = 100;
91
92        for _ in 0..epochs {
93            let mut gradients = vec![0.0; self.weights.len()];
94
95            for (features, target) in data {
96                let x = features.to_vector();
97                let predicted = self.predict_raw(&x);
98                let error = predicted - target;
99
100                // Gradient for bias
101                gradients[0] += error;
102
103                // Gradient for features
104                for (i, xi) in x.iter().enumerate() {
105                    gradients[i + 1] += error * xi;
106                }
107            }
108
109            // Update weights
110            let n = data.len().max(1) as f32;
111            for (i, g) in gradients.iter().enumerate() {
112                self.weights[i] -= learning_rate * g / n;
113            }
114        }
115
116        // Calculate MAPE on training data
117        let mut total_ape = 0.0;
118        for (features, target) in data {
119            let predicted = self.predict_raw(&features.to_vector());
120            total_ape += ((predicted - target) / target.max(1.0)).abs();
121        }
122        self.mape = total_ape / data.len().max(1) as f32;
123        self.sample_count = data.len();
124
125        Ok(())
126    }
127
128    pub(crate) fn predict_raw(&self, x: &[f32]) -> f32 {
129        let mut result = self.weights[0]; // bias
130        for (i, xi) in x.iter().enumerate() {
131            if i + 1 < self.weights.len() {
132                result += self.weights[i + 1] * xi;
133            }
134        }
135        // Convert from normalized to tok/s (scale ~1000)
136        (result * 1000.0).max(1.0)
137    }
138
139    /// Predict throughput for features using the linear model.
140    pub fn predict(&self, features: &TunerFeatures) -> ThroughputPrediction {
141        let x = features.to_vector();
142        let raw_predicted_tps = self.predict_raw(&x);
143
144        // v1.1.0: Roofline clamping - predictions must not exceed theoretical maximum
145        let theoretical_max_tps = Self::compute_roofline_bound(features);
146        let predicted_tps = raw_predicted_tps.min(theoretical_max_tps);
147
148        // Confidence based on training MAPE and feature validity
149        // Lower confidence if we hit the roofline cap
150        let roofline_penalty = if raw_predicted_tps > theoretical_max_tps {
151            0.9 // 10% confidence penalty for capped predictions
152        } else {
153            1.0
154        };
155        let confidence = (1.0 - self.mape).max(0.5) * roofline_penalty;
156
157        ThroughputPrediction {
158            predicted_tps,
159            confidence,
160            top_features: self.feature_importance.iter().take(5).cloned().collect(),
161        }
162    }
163
164    /// Compute theoretical maximum throughput based on roofline model (v1.1.0)
165    ///
166    /// For memory-bound LLM inference (decode phase):
167    /// max_tps = memory_bw_bytes_per_sec / bytes_per_token
168    /// bytes_per_token = model_params x bytes_per_param / batch_size
169    pub fn compute_roofline_bound(features: &TunerFeatures) -> f32 {
170        // Denormalize model params: normalized = (log10(b) + 1) / 3
171        // log10(b) = normalized * 3 - 1
172        // b = 10^(normalized * 3 - 1)
173        let model_params_b = 10.0_f32.powf(features.model_params_b * 3.0 - 1.0);
174
175        // Get bytes per param from quant type one-hot encoding
176        let bytes_per_param = Self::bytes_per_param_from_onehot(&features.quant_type_onehot);
177
178        // Denormalize memory bandwidth: normalized = bw / 3000 GB/s
179        let gpu_mem_bw_gbs = features.gpu_mem_bw_norm * 3000.0;
180
181        // Denormalize batch size: normalized = batch_size / 64
182        let batch_size = (features.batch_size_norm * 64.0).max(1.0);
183
184        // Roofline calculation:
185        // model_bytes = model_params_b * bytes_per_param * 1e9
186        // bytes_per_token = model_bytes / batch_size
187        // max_tps = (gpu_mem_bw_gbs * 1e9) / bytes_per_token
188        //         = (gpu_mem_bw_gbs * 1e9 * batch_size) / (model_params_b * bytes_per_param * 1e9)
189        //         = (gpu_mem_bw_gbs * batch_size) / (model_params_b * bytes_per_param)
190        let theoretical_max = (gpu_mem_bw_gbs * batch_size) / (model_params_b * bytes_per_param);
191
192        // Clamp to reasonable range (1 tok/s to 10000 tok/s)
193        theoretical_max.clamp(1.0, 10000.0)
194    }
195
196    /// Extract bytes per param from quant type one-hot encoding
197    pub fn bytes_per_param_from_onehot(onehot: &[f32; 8]) -> f32 {
198        // One-hot indices map to QuantType variants
199        // 0: Q4_0, 1: Q4_1, 2: Q4K, 3: Q5K, 4: Q6K, 5: Q8_0, 6: F16, 7: F32
200        let bytes_per_param = [0.5625, 0.5625, 0.5625, 0.6875, 0.8125, 1.0, 2.0, 4.0];
201
202        // Find the active index (max value in one-hot)
203        let idx = onehot
204            .iter()
205            .enumerate()
206            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
207            .map(|(i, _)| i)
208            // N-07 (Meyer DbC): default to Q4_0 (idx 0) if ambiguous, not Q4K.
209            .unwrap_or(0);
210
211        bytes_per_param[idx]
212    }
213}