/home/noah/src/realizar/src/bench/matrix.rs
Line | Count | Source |
1 | | //! Backend benchmark matrix for comparing compute implementations |
2 | | //! |
3 | | //! Extracted from bench/mod.rs (PMAT-802) to reduce module size. |
4 | | //! Contains: |
5 | | //! - ComputeBackendType enum (CPU, wgpu, CUDA) |
6 | | //! - BenchmarkMatrix for cross-backend comparison |
7 | | //! - MatrixBenchmarkConfig and related types |
8 | | |
9 | | #![allow(clippy::cast_precision_loss)] |
10 | | |
11 | | use std::fmt::Write; |
12 | | |
13 | | use serde::{Deserialize, Serialize}; |
14 | | |
15 | | use super::{chrono_timestamp, compute_cv, percentile, HardwareSpec, RuntimeType}; |
16 | | |
17 | | // ============================================================================ |
18 | | // Backend Benchmark Matrix (per Hoefler & Belli SC'15) |
19 | | // ============================================================================ |
20 | | |
21 | | /// Compute backend type for benchmark matrix |
22 | | /// |
23 | | /// Represents the different compute backends that can be benchmarked: |
24 | | /// - CPU: Scalar/SIMD operations via trueno CPU backend |
25 | | /// - Wgpu: Cross-platform GPU via trueno wgpu backend |
26 | | /// - Cuda: NVIDIA GPU via trueno-gpu PTX execution |
27 | | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] |
28 | | pub enum ComputeBackendType { |
29 | | /// CPU backend (scalar/SIMD via trueno) |
30 | | Cpu, |
31 | | /// wgpu GPU backend (cross-platform via trueno) |
32 | | Wgpu, |
33 | | /// CUDA GPU backend (NVIDIA via trueno-gpu) |
34 | | Cuda, |
35 | | } |
36 | | |
37 | | impl std::fmt::Display for ComputeBackendType { |
38 | 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
39 | 24 | match self { |
40 | 15 | Self::Cpu => write!(f, "cpu"), |
41 | 5 | Self::Wgpu => write!(f, "wgpu"), |
42 | 4 | Self::Cuda => write!(f, "cuda"), |
43 | | } |
44 | 24 | } |
45 | | } |
46 | | |
47 | | impl ComputeBackendType { |
48 | | /// Parse from string |
49 | | #[must_use] |
50 | 7 | pub fn parse(s: &str) -> Option<Self> { |
51 | 7 | match s.to_lowercase().as_str() { |
52 | 7 | "cpu" => Some(Self::Cpu)2 , |
53 | 5 | "wgpu" | "gpu"4 => Some(Self::Wgpu)2 , |
54 | 3 | "cuda" | "nvidia"2 => Some(Self::Cuda)2 , |
55 | 1 | _ => None, |
56 | | } |
57 | 7 | } |
58 | | |
59 | | /// All available backend types |
60 | | #[must_use] |
61 | 4 | pub fn all() -> Vec<Self> { |
62 | 4 | vec![Self::Cpu, Self::Wgpu, Self::Cuda] |
63 | 4 | } |
64 | | } |
65 | | |
66 | | /// Single entry in the benchmark matrix |
67 | | /// |
68 | | /// Represents results for one (runtime, backend) combination. |
69 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
70 | | pub struct MatrixBenchmarkEntry { |
71 | | /// Runtime type (realizar, llama-cpp, ollama, vllm) |
72 | | pub runtime: RuntimeType, |
73 | | /// Compute backend (cpu, wgpu, cuda) |
74 | | pub backend: ComputeBackendType, |
75 | | /// Model name/identifier |
76 | | pub model: String, |
77 | | /// Whether this configuration is available |
78 | | pub available: bool, |
79 | | /// p50 latency in milliseconds |
80 | | pub p50_latency_ms: f64, |
81 | | /// p99 latency in milliseconds |
82 | | pub p99_latency_ms: f64, |
83 | | /// Throughput in tokens per second |
84 | | pub throughput_tps: f64, |
85 | | /// Cold start time in milliseconds |
86 | | pub cold_start_ms: f64, |
87 | | /// Number of samples collected |
88 | | pub samples: usize, |
89 | | /// Final CV at stop |
90 | | pub cv_at_stop: f64, |
91 | | /// Additional notes (e.g., "GPU layers: 99") |
92 | | pub notes: String, |
93 | | } |
94 | | |
95 | | impl Default for MatrixBenchmarkEntry { |
96 | 5 | fn default() -> Self { |
97 | 5 | Self { |
98 | 5 | runtime: RuntimeType::Realizar, |
99 | 5 | backend: ComputeBackendType::Cpu, |
100 | 5 | model: String::new(), |
101 | 5 | available: false, |
102 | 5 | p50_latency_ms: 0.0, |
103 | 5 | p99_latency_ms: 0.0, |
104 | 5 | throughput_tps: 0.0, |
105 | 5 | cold_start_ms: 0.0, |
106 | 5 | samples: 0, |
107 | 5 | cv_at_stop: 0.0, |
108 | 5 | notes: String::new(), |
109 | 5 | } |
110 | 5 | } |
111 | | } |
112 | | |
113 | | impl MatrixBenchmarkEntry { |
114 | | /// Create a new unavailable entry (placeholder) |
115 | | #[must_use] |
116 | 5 | pub fn unavailable(runtime: RuntimeType, backend: ComputeBackendType) -> Self { |
117 | 5 | Self { |
118 | 5 | runtime, |
119 | 5 | backend, |
120 | 5 | available: false, |
121 | 5 | notes: "Backend not available".to_string(), |
122 | 5 | ..Default::default() |
123 | 5 | } |
124 | 5 | } |
125 | | |
126 | | /// Create entry from raw latency samples |
127 | | #[must_use] |
128 | 39 | pub fn from_samples( |
129 | 39 | runtime: RuntimeType, |
130 | 39 | backend: ComputeBackendType, |
131 | 39 | model: &str, |
132 | 39 | latencies_ms: &[f64], |
133 | 39 | throughputs_tps: &[f64], |
134 | 39 | cold_start_ms: f64, |
135 | 39 | ) -> Self { |
136 | 39 | let samples = latencies_ms.len(); |
137 | 39 | if samples == 0 { |
138 | 1 | return Self::unavailable(runtime, backend); |
139 | 38 | } |
140 | | |
141 | 38 | let p50_latency = percentile(latencies_ms, 50.0); |
142 | 38 | let p99_latency = percentile(latencies_ms, 99.0); |
143 | 38 | let throughput = if throughputs_tps.is_empty() { |
144 | 0 | 0.0 |
145 | | } else { |
146 | 38 | throughputs_tps.iter().sum::<f64>() / throughputs_tps.len() as f64 |
147 | | }; |
148 | 38 | let cv = compute_cv(latencies_ms); |
149 | | |
150 | 38 | Self { |
151 | 38 | runtime, |
152 | 38 | backend, |
153 | 38 | model: model.to_string(), |
154 | 38 | available: true, |
155 | 38 | p50_latency_ms: p50_latency, |
156 | 38 | p99_latency_ms: p99_latency, |
157 | 38 | throughput_tps: throughput, |
158 | 38 | cold_start_ms, |
159 | 38 | samples, |
160 | 38 | cv_at_stop: cv, |
161 | 38 | notes: String::new(), |
162 | 38 | } |
163 | 39 | } |
164 | | |
165 | | /// Add notes to the entry |
166 | | #[must_use] |
167 | 1 | pub fn with_notes(mut self, notes: &str) -> Self { |
168 | 1 | self.notes = notes.to_string(); |
169 | 1 | self |
170 | 1 | } |
171 | | } |
172 | | |
173 | | /// Complete benchmark matrix comparing runtimes across backends |
174 | | /// |
175 | | /// Per Hoefler & Belli SC'15, this matrix enables: |
176 | | /// - Reproducible comparisons across configurations |
177 | | /// - Statistical validity via CV-based stopping |
178 | | /// - Clear identification of performance characteristics |
179 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
180 | | pub struct BenchmarkMatrix { |
181 | | /// Schema version |
182 | | pub version: String, |
183 | | /// ISO 8601 timestamp |
184 | | pub timestamp: String, |
185 | | /// Model used for benchmarking |
186 | | pub model: String, |
187 | | /// Hardware specification |
188 | | pub hardware: HardwareSpec, |
189 | | /// Benchmark methodology |
190 | | pub methodology: String, |
191 | | /// CV threshold used |
192 | | pub cv_threshold: f64, |
193 | | /// Matrix entries indexed by (runtime, backend) |
194 | | pub entries: Vec<MatrixBenchmarkEntry>, |
195 | | } |
196 | | |
197 | | impl BenchmarkMatrix { |
198 | | /// Create a new empty matrix |
199 | | #[must_use] |
200 | 18 | pub fn new(model: &str, hardware: HardwareSpec) -> Self { |
201 | 18 | Self { |
202 | 18 | version: "1.1".to_string(), |
203 | 18 | timestamp: chrono_timestamp(), |
204 | 18 | model: model.to_string(), |
205 | 18 | hardware, |
206 | 18 | methodology: "CV-based stopping (Hoefler & Belli SC'15)".to_string(), |
207 | 18 | cv_threshold: 0.05, |
208 | 18 | entries: Vec::new(), |
209 | 18 | } |
210 | 18 | } |
211 | | |
212 | | /// Add an entry to the matrix |
213 | 32 | pub fn add_entry(&mut self, entry: MatrixBenchmarkEntry) { |
214 | | // Remove existing entry for same (runtime, backend) if present |
215 | 32 | self.entries |
216 | 32 | .retain(|e| e.runtime != entry.runtime23 || e.backend != entry.backend6 ); |
217 | 32 | self.entries.push(entry); |
218 | 32 | } |
219 | | |
220 | | /// Get entry for specific (runtime, backend) combination |
221 | | #[must_use] |
222 | 2 | pub fn get_entry( |
223 | 2 | &self, |
224 | 2 | runtime: RuntimeType, |
225 | 2 | backend: ComputeBackendType, |
226 | 2 | ) -> Option<&MatrixBenchmarkEntry> { |
227 | 2 | self.entries |
228 | 2 | .iter() |
229 | 2 | .find(|e| e.runtime == runtime && e.backend == backend1 ) |
230 | 2 | } |
231 | | |
232 | | /// Get all entries for a specific runtime |
233 | | #[must_use] |
234 | 2 | pub fn entries_for_runtime(&self, runtime: RuntimeType) -> Vec<&MatrixBenchmarkEntry> { |
235 | 2 | self.entries |
236 | 2 | .iter() |
237 | 6 | .filter2 (|e| e.runtime == runtime) |
238 | 2 | .collect() |
239 | 2 | } |
240 | | |
241 | | /// Get all entries for a specific backend |
242 | | #[must_use] |
243 | 14 | pub fn entries_for_backend(&self, backend: ComputeBackendType) -> Vec<&MatrixBenchmarkEntry> { |
244 | 14 | self.entries |
245 | 14 | .iter() |
246 | 40 | .filter14 (|e| e.backend == backend) |
247 | 14 | .collect() |
248 | 14 | } |
249 | | |
250 | | /// Find the fastest runtime for a given backend (by p50 latency) |
251 | | #[must_use] |
252 | 1 | pub fn fastest_for_backend( |
253 | 1 | &self, |
254 | 1 | backend: ComputeBackendType, |
255 | 1 | ) -> Option<&MatrixBenchmarkEntry> { |
256 | 1 | self.entries_for_backend(backend) |
257 | 1 | .into_iter() |
258 | 1 | .filter(|e| e.available) |
259 | 1 | .min_by(|a, b| { |
260 | 1 | a.p50_latency_ms |
261 | 1 | .partial_cmp(&b.p50_latency_ms) |
262 | 1 | .expect("test") |
263 | 1 | }) |
264 | 1 | } |
265 | | |
266 | | /// Find the highest throughput runtime for a given backend |
267 | | #[must_use] |
268 | 1 | pub fn highest_throughput_for_backend( |
269 | 1 | &self, |
270 | 1 | backend: ComputeBackendType, |
271 | 1 | ) -> Option<&MatrixBenchmarkEntry> { |
272 | 1 | self.entries_for_backend(backend) |
273 | 1 | .into_iter() |
274 | 1 | .filter(|e| e.available) |
275 | 1 | .max_by(|a, b| { |
276 | 1 | a.throughput_tps |
277 | 1 | .partial_cmp(&b.throughput_tps) |
278 | 1 | .expect("test") |
279 | 1 | }) |
280 | 1 | } |
281 | | |
282 | | /// Generate markdown table for README |
283 | | #[must_use] |
284 | 6 | pub fn to_markdown_table(&self) -> String { |
285 | 6 | let mut table = String::new(); |
286 | | |
287 | | // Header |
288 | 6 | table.push_str("| Runtime | Backend | p50 Latency | p99 Latency | Throughput | Cold Start | Samples | CV |\n"); |
289 | 6 | table.push_str("|---------|---------|-------------|-------------|------------|------------|---------|----|\n"); |
290 | | |
291 | | // Sort entries by runtime, then backend |
292 | 6 | let mut sorted_entries = self.entries.clone(); |
293 | 6 | sorted_entries.sort_by(|a, b| {2 |
294 | 2 | let runtime_cmp = format!("{:?}", a.runtime).cmp(&format!("{:?}", b.runtime)); |
295 | 2 | if runtime_cmp == std::cmp::Ordering::Equal { |
296 | 1 | format!("{}", a.backend).cmp(&format!("{}", b.backend)) |
297 | | } else { |
298 | 1 | runtime_cmp |
299 | | } |
300 | 2 | }); |
301 | | |
302 | 14 | for entry8 in &sorted_entries { |
303 | 8 | if entry.available { |
304 | 7 | let _ = writeln!( |
305 | 7 | table, |
306 | 7 | "| **{}** | {} | {:.1}ms | {:.1}ms | {:.1} tok/s | {:.0}ms | {} | {:.3} |", |
307 | 7 | format!("{:?}", entry.runtime).to_lowercase(), |
308 | 7 | entry.backend, |
309 | 7 | entry.p50_latency_ms, |
310 | 7 | entry.p99_latency_ms, |
311 | 7 | entry.throughput_tps, |
312 | 7 | entry.cold_start_ms, |
313 | 7 | entry.samples, |
314 | 7 | entry.cv_at_stop, |
315 | 7 | ); |
316 | 7 | } else { |
317 | 1 | let _ = writeln!( |
318 | 1 | table, |
319 | 1 | "| {} | {} | - | - | - | - | - | - |", |
320 | 1 | format!("{:?}", entry.runtime).to_lowercase(), |
321 | 1 | entry.backend, |
322 | 1 | ); |
323 | 1 | } |
324 | | } |
325 | | |
326 | 6 | table |
327 | 6 | } |
328 | | |
329 | | /// Serialize to JSON |
330 | | /// |
331 | | /// # Errors |
332 | | /// |
333 | | /// Returns error if serialization fails. |
334 | 1 | pub fn to_json(&self) -> Result<String, serde_json::Error> { |
335 | 1 | serde_json::to_string_pretty(self) |
336 | 1 | } |
337 | | |
338 | | /// Deserialize from JSON |
339 | | /// |
340 | | /// # Errors |
341 | | /// |
342 | | /// Returns error if JSON is invalid. |
343 | 1 | pub fn from_json(json: &str) -> Result<Self, serde_json::Error> { |
344 | 1 | serde_json::from_str(json) |
345 | 1 | } |
346 | | } |
347 | | |
348 | | /// Matrix benchmark runner configuration |
349 | | #[derive(Debug, Clone)] |
350 | | pub struct MatrixBenchmarkConfig { |
351 | | /// Runtimes to benchmark |
352 | | pub runtimes: Vec<RuntimeType>, |
353 | | /// Backends to benchmark |
354 | | pub backends: Vec<ComputeBackendType>, |
355 | | /// Model path |
356 | | pub model_path: String, |
357 | | /// Prompt for benchmarking |
358 | | pub prompt: String, |
359 | | /// Max tokens to generate |
360 | | pub max_tokens: usize, |
361 | | /// CV threshold for stopping |
362 | | pub cv_threshold: f64, |
363 | | /// Minimum samples |
364 | | pub min_samples: usize, |
365 | | /// Maximum samples (failsafe) |
366 | | pub max_samples: usize, |
367 | | /// Warmup iterations |
368 | | pub warmup_iterations: usize, |
369 | | } |
370 | | |
371 | | impl Default for MatrixBenchmarkConfig { |
372 | 1 | fn default() -> Self { |
373 | 1 | Self { |
374 | 1 | runtimes: vec![ |
375 | 1 | RuntimeType::Realizar, |
376 | 1 | RuntimeType::LlamaCpp, |
377 | 1 | RuntimeType::Ollama, |
378 | 1 | ], |
379 | 1 | backends: vec![ComputeBackendType::Cpu, ComputeBackendType::Wgpu], |
380 | 1 | model_path: String::new(), |
381 | 1 | prompt: "Explain machine learning in one sentence.".to_string(), |
382 | 1 | max_tokens: 50, |
383 | 1 | cv_threshold: 0.05, |
384 | 1 | min_samples: 30, |
385 | 1 | max_samples: 200, |
386 | 1 | warmup_iterations: 5, |
387 | 1 | } |
388 | 1 | } |
389 | | } |
390 | | |
391 | | /// Summary statistics for a single matrix column (backend) |
392 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
393 | | pub struct BackendSummary { |
394 | | /// Backend type |
395 | | pub backend: ComputeBackendType, |
396 | | /// Number of available runtimes |
397 | | pub available_runtimes: usize, |
398 | | /// Fastest runtime (by p50 latency) |
399 | | pub fastest_runtime: Option<String>, |
400 | | /// Fastest p50 latency |
401 | | pub fastest_p50_ms: f64, |
402 | | /// Highest throughput runtime |
403 | | pub highest_throughput_runtime: Option<String>, |
404 | | /// Highest throughput (tok/s) |
405 | | pub highest_throughput_tps: f64, |
406 | | } |
407 | | |
408 | | /// Summary of the entire benchmark matrix |
409 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
410 | | pub struct MatrixSummary { |
411 | | /// Total entries in matrix |
412 | | pub total_entries: usize, |
413 | | /// Number of available entries |
414 | | pub available_entries: usize, |
415 | | /// Per-backend summaries |
416 | | pub backend_summaries: Vec<BackendSummary>, |
417 | | /// Overall fastest (runtime, backend) combination |
418 | | pub overall_fastest: Option<(String, String)>, |
419 | | /// Overall highest throughput (runtime, backend) |
420 | | pub overall_highest_throughput: Option<(String, String)>, |
421 | | } |
422 | | |
423 | | impl BenchmarkMatrix { |
424 | | /// Generate summary statistics |
425 | | #[must_use] |
426 | 3 | pub fn summary(&self) -> MatrixSummary { |
427 | 3 | let total_entries = self.entries.len(); |
428 | 3 | let available_entries = self.entries.iter().filter(|e| e.available).count(); |
429 | | |
430 | 3 | let mut backend_summaries = Vec::new(); |
431 | 9 | for backend in ComputeBackendType::all3 () { |
432 | 9 | let entries: Vec<_> = self.entries_for_backend(backend); |
433 | 9 | let available: Vec<_> = entries.iter().filter(|e| e.available).collect(); |
434 | | |
435 | 9 | let fastest = available.iter().min_by(|a, b| {4 |
436 | 4 | a.p50_latency_ms |
437 | 4 | .partial_cmp(&b.p50_latency_ms) |
438 | 4 | .expect("test") |
439 | 4 | }); |
440 | 9 | let highest_tp = available.iter().max_by(|a, b| {4 |
441 | 4 | a.throughput_tps |
442 | 4 | .partial_cmp(&b.throughput_tps) |
443 | 4 | .expect("test") |
444 | 4 | }); |
445 | | |
446 | 9 | backend_summaries.push(BackendSummary { |
447 | 9 | backend, |
448 | 9 | available_runtimes: available.len(), |
449 | 9 | fastest_runtime: fastest.map(|e| format!4 ("{:?}"4 , e.runtime).to_lowercase4 ()), |
450 | 9 | fastest_p50_ms: fastest.map_or(0.0, |e| e.p50_latency_ms), |
451 | 9 | highest_throughput_runtime: highest_tp |
452 | 9 | .map(|e| format!4 ("{:?}"4 , e.runtime).to_lowercase4 ()), |
453 | 9 | highest_throughput_tps: highest_tp.map_or(0.0, |e| e.throughput_tps), |
454 | | }); |
455 | | } |
456 | | |
457 | 3 | let available = self.entries.iter().filter(|e| e.available); |
458 | 3 | let overall_fastest = available |
459 | 3 | .clone() |
460 | 5 | .min_by3 (|a, b| { |
461 | 5 | a.p50_latency_ms |
462 | 5 | .partial_cmp(&b.p50_latency_ms) |
463 | 5 | .expect("test") |
464 | 5 | }) |
465 | 3 | .map(|e| { |
466 | 3 | ( |
467 | 3 | format!("{:?}", e.runtime).to_lowercase(), |
468 | 3 | e.backend.to_string(), |
469 | 3 | ) |
470 | 3 | }); |
471 | 3 | let overall_highest_throughput = available |
472 | 5 | .max_by3 (|a, b| { |
473 | 5 | a.throughput_tps |
474 | 5 | .partial_cmp(&b.throughput_tps) |
475 | 5 | .expect("test") |
476 | 5 | }) |
477 | 3 | .map(|e| { |
478 | 3 | ( |
479 | 3 | format!("{:?}", e.runtime).to_lowercase(), |
480 | 3 | e.backend.to_string(), |
481 | 3 | ) |
482 | 3 | }); |
483 | | |
484 | 3 | MatrixSummary { |
485 | 3 | total_entries, |
486 | 3 | available_entries, |
487 | 3 | backend_summaries, |
488 | 3 | overall_fastest, |
489 | 3 | overall_highest_throughput, |
490 | 3 | } |
491 | 3 | } |
492 | | } |