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}