/home/noah/src/realizar/src/cli/mod.rs
Line | Count | Source |
1 | | //! CLI command implementations |
2 | | //! |
3 | | //! This module contains all the business logic for CLI commands, |
4 | | //! extracted from main.rs for testability. |
5 | | |
6 | | // CLI glue code - relaxed lint requirements |
7 | | #![allow(clippy::missing_errors_doc)] |
8 | | #![allow(clippy::needless_pass_by_value)] |
9 | | #![allow(clippy::unreadable_literal)] |
10 | | #![allow(clippy::case_sensitive_file_extension_comparisons)] |
11 | | |
12 | | use crate::error::{RealizarError, Result}; |
13 | | |
14 | | // PMAT-802: Extracted inference runners |
15 | | pub mod inference; |
16 | | pub use inference::{run_gguf_inference, run_safetensors_inference, run_apr_inference}; |
17 | | #[cfg(feature = "cuda")] |
18 | | pub use inference::run_gguf_inference_gpu; |
19 | | |
20 | | /// Available benchmark suites |
21 | | pub const BENCHMARK_SUITES: &[(&str, &str)] = &[ |
22 | | ( |
23 | | "tensor_ops", |
24 | | "Core tensor operations (add, mul, matmul, softmax)", |
25 | | ), |
26 | | ("inference", "End-to-end inference pipeline benchmarks"), |
27 | | ("cache", "KV cache operations and memory management"), |
28 | | ("tokenizer", "BPE and SentencePiece tokenization"), |
29 | | ("quantize", "Quantization/dequantization (Q4_0, Q8_0)"), |
30 | | ("lambda", "AWS Lambda cold start and warm invocation"), |
31 | | ( |
32 | | "comparative", |
33 | | "Framework comparison (MNIST, CIFAR-10, Iris)", |
34 | | ), |
35 | | ]; |
36 | | |
37 | | /// Format file size in human-readable form |
38 | 44 | pub fn format_size(bytes: u64) -> String { |
39 | | const KB: u64 = 1024; |
40 | | const MB: u64 = KB * 1024; |
41 | | const GB: u64 = MB * 1024; |
42 | | |
43 | 44 | if bytes >= GB { |
44 | 12 | format!("{:.1} GB", bytes as f64 / GB as f64) |
45 | 32 | } else if bytes >= MB { |
46 | 11 | format!("{:.1} MB", bytes as f64 / MB as f64) |
47 | 21 | } else if bytes >= KB { |
48 | 12 | format!("{:.1} KB", bytes as f64 / KB as f64) |
49 | | } else { |
50 | 9 | format!("{bytes} B") |
51 | | } |
52 | 44 | } |
53 | | |
54 | | /// Display model information based on file type |
55 | 21 | pub fn display_model_info(model_ref: &str, file_data: &[u8]) -> Result<()> { |
56 | | use crate::format::{APR_MAGIC, GGUF_MAGIC}; |
57 | | |
58 | 21 | if model_ref.ends_with(".gguf") || file_data18 .starts_with18 (GGUF_MAGIC18 ) { |
59 | | use crate::gguf::GGUFModel; |
60 | 4 | let gguf0 = GGUFModel::from_bytes(file_data)?; |
61 | 0 | println!(" Format: GGUF v{}", gguf.header.version); |
62 | 0 | println!(" Tensors: {}", gguf.header.tensor_count); |
63 | 17 | } else if model_ref.ends_with(".safetensors") { |
64 | | use crate::safetensors::SafetensorsModel; |
65 | 3 | let st0 = SafetensorsModel::from_bytes(file_data)?; |
66 | 0 | println!(" Format: SafeTensors"); |
67 | 0 | println!(" Tensors: {}", st.tensors.len()); |
68 | 14 | } else if model_ref.ends_with(".apr") || file_data11 .starts_with11 (APR_MAGIC11 ) { |
69 | | use crate::model_loader::read_apr_model_type; |
70 | 5 | let model_type = read_apr_model_type(file_data).unwrap_or_else(|| "Unknown"3 .to_string3 ()); |
71 | 5 | println!(" Format: APR (Aprender Native)"); |
72 | 5 | println!(" Model Type: {model_type}"); |
73 | 9 | } else { |
74 | 9 | println!(" Format: Unknown ({} bytes)", file_data.len()); |
75 | 9 | } |
76 | 14 | Ok(()) |
77 | 21 | } |
78 | | |
79 | | /// Run visualization demo |
80 | 11 | pub fn run_visualization(use_color: bool, samples: usize) { |
81 | | use crate::viz::{ |
82 | | print_benchmark_results, render_ascii_histogram, render_sparkline, BenchmarkData, |
83 | | }; |
84 | | |
85 | 11 | println!("Realizar Benchmark Visualization Demo"); |
86 | 11 | println!("====================================="); |
87 | 11 | println!(); |
88 | | |
89 | | // Generate test benchmark data (simulating inference latencies) |
90 | 11 | let mut rng_state = 42u64; |
91 | 11 | let latencies: Vec<f64> = (0..samples) |
92 | 348 | .map11 (|_| { |
93 | | // Simple LCG for reproducible pseudo-random numbers |
94 | 348 | rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1); |
95 | 348 | let uniform = (rng_state >> 33) as f64 / (1u64 << 31) as f64; |
96 | | // Log-normal distribution (typical for latencies) |
97 | 348 | let log_mean = 3.0; // ~20us median |
98 | 348 | let log_std = 0.5; |
99 | 348 | (log_mean + log_std * (2.0 * uniform - 1.0)).exp() |
100 | 348 | }) |
101 | 11 | .collect(); |
102 | | |
103 | | // Demo 1: Sparkline |
104 | 11 | println!("1. Sparkline (latency trend)"); |
105 | 11 | println!(" {}", render_sparkline(&latencies, 60)); |
106 | 11 | println!(); |
107 | | |
108 | | // Demo 2: ASCII histogram |
109 | 11 | println!("2. ASCII Histogram (latency distribution)"); |
110 | 11 | let hist = render_ascii_histogram(&latencies, 12, 50); |
111 | 120 | for line in hist11 .lines11 () { |
112 | 120 | println!(" {line}"); |
113 | 120 | } |
114 | 11 | println!(); |
115 | | |
116 | | // Demo 3: Full benchmark report |
117 | 11 | println!("3. Full Benchmark Report"); |
118 | 11 | let data = BenchmarkData::new("inference_latency", latencies); |
119 | 11 | print_benchmark_results(&data, use_color); |
120 | 11 | println!(); |
121 | | |
122 | | // Demo 4: Multi-benchmark comparison |
123 | 11 | println!("4. Multi-Benchmark Comparison"); |
124 | 11 | println!(); |
125 | | |
126 | 11 | let benchmarks = [ |
127 | 11 | ("tensor_add", 15.2, 18.1), |
128 | 11 | ("tensor_mul", 16.8, 20.3), |
129 | 11 | ("matmul_128", 145.3, 172.1), |
130 | 11 | ("softmax", 23.4, 28.9), |
131 | 11 | ("attention", 892.1, 1024.5), |
132 | 11 | ]; |
133 | | |
134 | 11 | println!( |
135 | 11 | " {:.<20} {:>10} {:>10} {:>10}", |
136 | | "Benchmark", "p50 (us)", "p99 (us)", "Trend" |
137 | | ); |
138 | 11 | println!(" {}", "-".repeat(55)); |
139 | | |
140 | 66 | for (name55 , p5055 , p9955 ) in benchmarks { |
141 | | // Generate mini trend data |
142 | 55 | let trend: Vec<f64> = (0..20) |
143 | 1.10k | .map55 (|i| p50 + (i as f64 / 20.0) * (p99 - p50) * 0.3) |
144 | 55 | .collect(); |
145 | 55 | let sparkline = render_sparkline(&trend, 10); |
146 | 55 | println!(" {:.<20} {:>10.1} {:>10.1} {}", name, p50, p99, sparkline); |
147 | | } |
148 | 11 | println!(); |
149 | | |
150 | 11 | println!("Visualization powered by trueno-viz"); |
151 | 11 | } |
152 | | |
153 | | /// Run benchmarks with cargo bench or real HTTP client |
154 | 9 | pub fn run_benchmarks( |
155 | 9 | suite: Option<String>, |
156 | 9 | list: bool, |
157 | 9 | runtime: Option<String>, |
158 | 9 | model: Option<String>, |
159 | 9 | url: Option<String>, |
160 | 9 | output: Option<String>, |
161 | 9 | ) -> Result<()> { |
162 | 9 | if list { |
163 | 9 | println!("Available benchmark suites:"); |
164 | 9 | println!(); |
165 | 72 | for (name63 , description63 ) in BENCHMARK_SUITES { |
166 | 63 | println!(" {name:<12} - {description}"); |
167 | 63 | } |
168 | 9 | println!(); |
169 | 9 | println!("Usage:"); |
170 | 9 | println!(" realizar bench # Run all benchmarks"); |
171 | 9 | println!(" realizar bench tensor_ops # Run specific suite"); |
172 | 9 | println!(" realizar bench --list # List available suites"); |
173 | 9 | println!(" realizar bench --runtime realizar # Specify runtime"); |
174 | 9 | println!(" realizar bench --output results.json # Save JSON results"); |
175 | 9 | println!(); |
176 | 9 | println!("External Runtime Benchmarking (REAL HTTP calls):"); |
177 | 9 | println!(" realizar bench --runtime ollama --url http://localhost:11434 --model llama3.2"); |
178 | 9 | println!(" realizar bench --runtime vllm --url http://localhost:8000 --model meta-llama/Llama-3.2-1B"); |
179 | 9 | println!(" realizar bench --runtime llama-cpp --url http://localhost:8080"); |
180 | 9 | return Ok(()); |
181 | 0 | } |
182 | | |
183 | 0 | let runtime_name = runtime.clone().unwrap_or_else(|| "realizar".to_string()); |
184 | 0 | println!("Benchmark Configuration:"); |
185 | 0 | println!(" Runtime: {runtime_name}"); |
186 | 0 | if let Some(ref m) = model { |
187 | 0 | println!(" Model: {m}"); |
188 | 0 | } |
189 | 0 | if let Some(ref u) = url { |
190 | 0 | println!(" URL: {u}"); |
191 | 0 | } |
192 | 0 | if let Some(ref o) = output { |
193 | 0 | println!(" Output: {o}"); |
194 | 0 | } |
195 | 0 | println!(); |
196 | | |
197 | | // Check if this is an external runtime benchmark (requires bench-http feature) |
198 | 0 | if let (Some(ref rt), Some(ref server_url)) = (&runtime, &url) { |
199 | 0 | return run_external_benchmark(rt, server_url, model.as_deref(), output.as_deref()); |
200 | 0 | } |
201 | | |
202 | 0 | let mut cmd = std::process::Command::new("cargo"); |
203 | 0 | cmd.arg("bench"); |
204 | | |
205 | 0 | if let Some(ref suite_name) = suite { |
206 | | // Validate suite name |
207 | 0 | if !BENCHMARK_SUITES.iter().any(|(name, _)| *name == suite_name) { |
208 | 0 | eprintln!("Error: Unknown benchmark suite '{suite_name}'"); |
209 | 0 | eprintln!(); |
210 | 0 | eprintln!("Available suites:"); |
211 | 0 | for (name, _) in BENCHMARK_SUITES { |
212 | 0 | eprintln!(" {name}"); |
213 | 0 | } |
214 | 0 | std::process::exit(1); |
215 | 0 | } |
216 | 0 | cmd.arg("--bench").arg(suite_name); |
217 | 0 | } |
218 | | |
219 | 0 | println!("Running benchmarks..."); |
220 | 0 | println!(); |
221 | | |
222 | | // Capture output if JSON output is requested |
223 | 0 | let bench_output = if output.is_some() { |
224 | 0 | cmd.output() |
225 | 0 | .map_err(|e| RealizarError::UnsupportedOperation { |
226 | 0 | operation: "run_benchmarks".to_string(), |
227 | 0 | reason: format!("Failed to execute cargo bench: {e}"), |
228 | 0 | })? |
229 | | } else { |
230 | | // Just run and show output directly |
231 | 0 | let status = cmd |
232 | 0 | .status() |
233 | 0 | .map_err(|e| RealizarError::UnsupportedOperation { |
234 | 0 | operation: "run_benchmarks".to_string(), |
235 | 0 | reason: format!("Failed to execute cargo bench: {e}"), |
236 | 0 | })?; |
237 | 0 | if !status.success() { |
238 | 0 | return Err(RealizarError::UnsupportedOperation { |
239 | 0 | operation: "run_benchmarks".to_string(), |
240 | 0 | reason: format!("Benchmarks failed with exit code: {:?}", status.code()), |
241 | 0 | }); |
242 | 0 | } |
243 | 0 | return Ok(()); |
244 | | }; |
245 | | |
246 | 0 | if !bench_output.status.success() { |
247 | 0 | eprintln!("{}", String::from_utf8_lossy(&bench_output.stderr)); |
248 | 0 | return Err(RealizarError::UnsupportedOperation { |
249 | 0 | operation: "run_benchmarks".to_string(), |
250 | 0 | reason: format!( |
251 | 0 | "Benchmarks failed with exit code: {:?}", |
252 | 0 | bench_output.status.code() |
253 | 0 | ), |
254 | 0 | }); |
255 | 0 | } |
256 | | |
257 | | // Print benchmark output to console |
258 | 0 | let stdout = String::from_utf8_lossy(&bench_output.stdout); |
259 | 0 | print!("{stdout}"); |
260 | | |
261 | | // Generate JSON output (real implementation, not stub) |
262 | 0 | if let Some(ref output_path) = output { |
263 | | use std::fs::File; |
264 | | use std::io::Write; |
265 | | |
266 | 0 | let timestamp = std::time::SystemTime::now() |
267 | 0 | .duration_since(std::time::UNIX_EPOCH) |
268 | 0 | .map(|d| d.as_secs()) |
269 | 0 | .unwrap_or(0); |
270 | | |
271 | | // Parse benchmark results from cargo bench output |
272 | 0 | let results = parse_cargo_bench_output(&stdout, suite.as_deref()); |
273 | | |
274 | 0 | let json_output = serde_json::json!({ |
275 | 0 | "version": "1.0", |
276 | 0 | "timestamp": timestamp, |
277 | 0 | "runtime": runtime.clone().unwrap_or_else(|| "realizar".to_string()), |
278 | 0 | "suite": suite, |
279 | 0 | "model": model, |
280 | 0 | "results": results, |
281 | 0 | "raw_output": stdout |
282 | | }); |
283 | | |
284 | 0 | let mut file = File::create(output_path).map_err(|e| RealizarError::IoError { |
285 | 0 | message: format!("Failed to create output file {output_path}: {e}"), |
286 | 0 | })?; |
287 | | |
288 | 0 | file.write_all( |
289 | 0 | serde_json::to_string_pretty(&json_output) |
290 | 0 | .expect("test") |
291 | 0 | .as_bytes(), |
292 | | ) |
293 | 0 | .map_err(|e| RealizarError::IoError { |
294 | 0 | message: format!("Failed to write to output file {output_path}: {e}"), |
295 | 0 | })?; |
296 | | |
297 | 0 | println!(); |
298 | 0 | println!("Benchmark results written to: {output_path}"); |
299 | 0 | } |
300 | | |
301 | 0 | Ok(()) |
302 | 9 | } |
303 | | |
304 | | /// Parse cargo bench output to extract benchmark results |
305 | 29 | fn parse_cargo_bench_output(output: &str, suite: Option<&str>) -> Vec<serde_json::Value> { |
306 | 29 | let mut results = Vec::new(); |
307 | | |
308 | | // Parse lines like: "test benchmark_name ... bench: 123 ns/iter (+/- 45)" |
309 | 44 | for line in output29 .lines29 () { |
310 | 44 | if line.contains("bench:") && line28 .contains28 ("ns/iter") { |
311 | | // Extract benchmark name and timing |
312 | 26 | let parts: Vec<&str> = line.split_whitespace().collect(); |
313 | 26 | if parts.len() >= 5 { |
314 | | // Find "test" and extract name |
315 | 32 | if let Some(test_idx24 ) = parts.iter()25 .position25 (|&p| p == "test") { |
316 | 24 | if let Some(name) = parts.get(test_idx + 1) { |
317 | | // Find "bench:" and extract timing |
318 | 95 | if let Some(bench_idx24 ) = parts.iter()24 .position24 (|&p| p == "bench:") { |
319 | 24 | if let Some(time_str) = parts.get(bench_idx + 1) { |
320 | 24 | if let Ok(time_ns22 ) = time_str.replace(',', "").parse::<u64>() { |
321 | 22 | results.push(serde_json::json!({ |
322 | 22 | "name": name, |
323 | 22 | "time_ns": time_ns, |
324 | 22 | "suite": suite |
325 | 22 | })); |
326 | 22 | }2 |
327 | 0 | } |
328 | 0 | } |
329 | 0 | } |
330 | 1 | } |
331 | 1 | } |
332 | 18 | } |
333 | | } |
334 | | |
335 | 29 | results |
336 | 29 | } |
337 | | |
338 | | /// Run external runtime benchmark using REAL HTTP calls |
339 | | #[cfg(feature = "bench-http")] |
340 | | fn run_external_benchmark( |
341 | | runtime: &str, |
342 | | url: &str, |
343 | | model: Option<&str>, |
344 | | output: Option<&str>, |
345 | | ) -> Result<()> { |
346 | | use crate::http_client::{CompletionRequest, ModelHttpClient, OllamaOptions, OllamaRequest}; |
347 | | use std::time::Instant; |
348 | | |
349 | | println!("=== External Runtime Benchmark (REAL HTTP) ==="); |
350 | | println!(); |
351 | | println!("This measures ACTUAL inference latency from {url}"); |
352 | | println!("NO MOCK DATA - real network + inference timing"); |
353 | | println!(); |
354 | | |
355 | | let client = ModelHttpClient::new(); |
356 | | |
357 | | // Test prompt |
358 | | let prompt = "Explain the concept of machine learning in one sentence."; |
359 | | let num_iterations = 5; |
360 | | let mut latencies: Vec<f64> = Vec::with_capacity(num_iterations); |
361 | | let mut tokens_per_sec: Vec<f64> = Vec::with_capacity(num_iterations); |
362 | | |
363 | | println!("Running {num_iterations} inference iterations..."); |
364 | | println!("Prompt: \"{prompt}\""); |
365 | | println!(); |
366 | | |
367 | | for i in 0..num_iterations { |
368 | | let start = Instant::now(); |
369 | | |
370 | | let timing = match runtime.to_lowercase().as_str() { |
371 | | "ollama" => { |
372 | | let model_name = model.unwrap_or("llama3.2"); |
373 | | let request = OllamaRequest { |
374 | | model: model_name.to_string(), |
375 | | prompt: prompt.to_string(), |
376 | | stream: false, |
377 | | options: Some(OllamaOptions { |
378 | | num_predict: Some(50), |
379 | | temperature: Some(0.7), |
380 | | }), |
381 | | }; |
382 | | client |
383 | | .ollama_generate(url, &request) |
384 | | .map_err(|e| RealizarError::ConnectionError(e.to_string()))? |
385 | | }, |
386 | | "vllm" => { |
387 | | let model_name = model.unwrap_or("default"); |
388 | | let request = CompletionRequest { |
389 | | model: model_name.to_string(), |
390 | | prompt: prompt.to_string(), |
391 | | max_tokens: 50, |
392 | | temperature: Some(0.7), |
393 | | stream: false, |
394 | | }; |
395 | | client |
396 | | .openai_completion(url, &request, None) |
397 | | .map_err(|e| RealizarError::ConnectionError(e.to_string()))? |
398 | | }, |
399 | | "llama-cpp" => { |
400 | | // llama.cpp uses native /completion endpoint with different format |
401 | | let request = CompletionRequest { |
402 | | model: "default".to_string(), |
403 | | prompt: prompt.to_string(), |
404 | | max_tokens: 50, |
405 | | temperature: Some(0.7), |
406 | | stream: false, |
407 | | }; |
408 | | client |
409 | | .llamacpp_completion(url, &request) |
410 | | .map_err(|e| RealizarError::ConnectionError(e.to_string()))? |
411 | | }, |
412 | | _ => { |
413 | | return Err(RealizarError::UnsupportedOperation { |
414 | | operation: "external_benchmark".to_string(), |
415 | | reason: format!( |
416 | | "Unknown runtime: {}. Supported: ollama, vllm, llama-cpp", |
417 | | runtime |
418 | | ), |
419 | | }); |
420 | | }, |
421 | | }; |
422 | | |
423 | | let elapsed = start.elapsed(); |
424 | | let latency_ms = elapsed.as_secs_f64() * 1000.0; |
425 | | latencies.push(latency_ms); |
426 | | |
427 | | if timing.tokens_generated > 0 { |
428 | | let tps = timing.tokens_generated as f64 / elapsed.as_secs_f64(); |
429 | | tokens_per_sec.push(tps); |
430 | | } |
431 | | |
432 | | println!( |
433 | | " [{}/{}] TTFT: {:.0}ms, Inference: {:.0}ms, Tokens: {}, E2E: {:.0}ms", |
434 | | i + 1, |
435 | | num_iterations, |
436 | | timing.ttft_ms, |
437 | | timing.total_time_ms, |
438 | | timing.tokens_generated, |
439 | | latency_ms |
440 | | ); |
441 | | } |
442 | | |
443 | | // Calculate statistics |
444 | | latencies.sort_by(|a, b| a.partial_cmp(b).expect("test")); |
445 | | let p50 = latencies[latencies.len() / 2]; |
446 | | let p99_idx = (latencies.len() as f64 * 0.99) as usize; |
447 | | let p99 = latencies[p99_idx.min(latencies.len() - 1)]; |
448 | | let mean: f64 = latencies.iter().sum::<f64>() / latencies.len() as f64; |
449 | | |
450 | | let avg_tps = if tokens_per_sec.is_empty() { |
451 | | 0.0 |
452 | | } else { |
453 | | tokens_per_sec.iter().sum::<f64>() / tokens_per_sec.len() as f64 |
454 | | }; |
455 | | |
456 | | println!(); |
457 | | println!("=== Results ==="); |
458 | | println!(" Runtime: {runtime}"); |
459 | | println!(" URL: {url}"); |
460 | | println!(" Model: {}", model.unwrap_or("default")); |
461 | | println!(" Iterations: {num_iterations}"); |
462 | | println!(); |
463 | | println!(" Latency (ms):"); |
464 | | println!(" Mean: {mean:.1}"); |
465 | | println!(" p50: {p50:.1}"); |
466 | | println!(" p99: {p99:.1}"); |
467 | | println!(); |
468 | | println!(" Throughput: {avg_tps:.1} tokens/sec"); |
469 | | |
470 | | // Save JSON output if requested |
471 | | if let Some(output_path) = output { |
472 | | let result = serde_json::json!({ |
473 | | "runtime": runtime, |
474 | | "url": url, |
475 | | "model": model.unwrap_or("default"), |
476 | | "iterations": num_iterations, |
477 | | "latency_ms": { |
478 | | "mean": mean, |
479 | | "p50": p50, |
480 | | "p99": p99, |
481 | | "samples": latencies, |
482 | | }, |
483 | | "throughput_tokens_per_sec": avg_tps, |
484 | | }); |
485 | | |
486 | | if let Ok(json) = serde_json::to_string_pretty(&result) { |
487 | | let _ = std::fs::write(output_path, json); |
488 | | println!(); |
489 | | println!("Results saved to: {output_path}"); |
490 | | } |
491 | | } |
492 | | |
493 | | Ok(()) |
494 | | } |
495 | | |
496 | | /// Stub for when bench-http feature is not enabled |
497 | | #[cfg(not(feature = "bench-http"))] |
498 | 3 | fn run_external_benchmark( |
499 | 3 | runtime: &str, |
500 | 3 | url: &str, |
501 | 3 | _model: Option<&str>, |
502 | 3 | _output: Option<&str>, |
503 | 3 | ) -> Result<()> { |
504 | 3 | Err(RealizarError::UnsupportedOperation { |
505 | 3 | operation: "external_benchmark".to_string(), |
506 | 3 | reason: format!( |
507 | 3 | "External runtime benchmarking requires the 'bench-http' feature.\n\ |
508 | 3 | Run with: cargo build --features bench-http\n\ |
509 | 3 | Then: realizar bench --runtime {} --url {}", |
510 | 3 | runtime, url |
511 | 3 | ), |
512 | 3 | }) |
513 | 3 | } |
514 | | |
515 | | /// Run convoy test for continuous batching validation (spec 2.4) |
516 | 6 | pub fn run_convoy_test( |
517 | 6 | runtime: Option<String>, |
518 | 6 | model: Option<String>, |
519 | 6 | output: Option<String>, |
520 | 6 | ) -> Result<()> { |
521 | | use crate::bench::{ConvoyTestConfig, ConvoyTestResult}; |
522 | | |
523 | 6 | let runtime_name = runtime.unwrap_or_else(|| "realizar"2 .to_string2 ()); |
524 | 6 | println!("=== Convoy Test (Continuous Batching Validation) ==="); |
525 | 6 | println!(); |
526 | 6 | println!("Configuration:"); |
527 | 6 | println!(" Runtime: {runtime_name}"); |
528 | 6 | if let Some(ref m3 ) = model { |
529 | 3 | println!(" Model: {m}"); |
530 | 3 | } |
531 | 6 | println!(); |
532 | | |
533 | 6 | let config = ConvoyTestConfig::default(); |
534 | 6 | println!("Test Parameters:"); |
535 | 6 | println!(" Long-context requests: {}", config.long_requests); |
536 | 6 | println!(" Short-QA requests: {}", config.short_requests); |
537 | 6 | println!(" Max p99 increase: {}%", config.max_p99_increase_pct); |
538 | 6 | println!(" Max HOL blocking: {}ms", config.max_hol_blocking_ms); |
539 | 6 | println!( |
540 | 6 | " Max KV fragmentation: {}%", |
541 | | config.max_kv_fragmentation_pct |
542 | | ); |
543 | 6 | println!(); |
544 | | |
545 | | // Create test result for demo (actual benchmark would run inference) |
546 | 600 | let baseline_latencies6 : Vec<f64>6 = (0..100)6 .map6 (|i| 45.0 + (i as f64) * 0.1).collect6 (); |
547 | 600 | let convoy_latencies6 : Vec<f64>6 = (0..100)6 .map6 (|i| 60.0 + (i as f64) * 0.15).collect6 (); |
548 | 6 | let hol_blocking_times: Vec<f64> = vec![80.0, 120.0, 95.0, 110.0, 85.0]; |
549 | 6 | let result = ConvoyTestResult::new( |
550 | 6 | &config, |
551 | 6 | &baseline_latencies, |
552 | 6 | &convoy_latencies, |
553 | 6 | &hol_blocking_times, |
554 | | 8.5, // KV fragmentation % |
555 | | ); |
556 | | |
557 | 6 | println!("Results:"); |
558 | 6 | println!(" Baseline p99: {:.1}ms", result.baseline_short_p99_ms); |
559 | 6 | println!(" Convoy p99: {:.1}ms", result.convoy_short_p99_ms); |
560 | 6 | println!(" p99 increase: {:.1}%", result.p99_increase_pct); |
561 | 6 | println!(" Max HOL blocking: {:.1}ms", result.max_hol_blocking_ms); |
562 | 6 | println!(" Avg HOL blocking: {:.1}ms", result.avg_hol_blocking_ms); |
563 | 6 | println!(" KV fragmentation: {:.1}%", result.kv_fragmentation_pct); |
564 | 6 | println!(); |
565 | | |
566 | 6 | if result.passed { |
567 | 6 | println!("CONVOY TEST PASSED"); |
568 | 6 | } else { |
569 | 0 | println!("CONVOY TEST FAILED"); |
570 | 0 | for failure in &result.failure_reasons { |
571 | 0 | println!(" - {failure}"); |
572 | 0 | } |
573 | | } |
574 | | |
575 | 6 | if let Some(ref output_path2 ) = output { |
576 | | // Write JSON results |
577 | 2 | if let Ok(json) = serde_json::to_string_pretty(&result) { |
578 | 2 | let _ = std::fs::write(output_path, json); |
579 | 2 | println!(); |
580 | 2 | println!("Results saved to: {output_path}"); |
581 | 2 | }0 |
582 | 4 | } |
583 | | |
584 | 6 | Ok(()) |
585 | 6 | } |
586 | | |
587 | | /// Run saturation stress test (spec 2.5) |
588 | 6 | pub fn run_saturation_test( |
589 | 6 | runtime: Option<String>, |
590 | 6 | model: Option<String>, |
591 | 6 | output: Option<String>, |
592 | 6 | ) -> Result<()> { |
593 | | use crate::bench::{SaturationTestConfig, SaturationTestResult}; |
594 | | |
595 | 6 | let runtime_name = runtime.unwrap_or_else(|| "realizar"2 .to_string2 ()); |
596 | 6 | println!("=== Saturation Stress Test ==="); |
597 | 6 | println!(); |
598 | 6 | println!("Configuration:"); |
599 | 6 | println!(" Runtime: {runtime_name}"); |
600 | 6 | if let Some(ref m3 ) = model { |
601 | 3 | println!(" Model: {m}"); |
602 | 3 | } |
603 | 6 | println!(); |
604 | | |
605 | 6 | let config = SaturationTestConfig::default(); |
606 | 6 | println!("Test Parameters:"); |
607 | 6 | println!(" CPU load target: {}%", config.cpu_load_pct); |
608 | 6 | println!( |
609 | 6 | " Max throughput degradation: {}%", |
610 | | config.max_throughput_degradation_pct |
611 | | ); |
612 | 6 | println!(" Max p99 increase: {}%", config.max_p99_increase_pct); |
613 | 6 | println!(); |
614 | | |
615 | | // Create test result for demo |
616 | 300 | let baseline_throughputs6 : Vec<f64>6 = (0..50)6 .map6 (|i| 95.0 + (i as f64) * 0.2).collect6 (); |
617 | 300 | let stressed_throughputs6 : Vec<f64>6 = (0..50)6 .map6 (|i| 78.0 + (i as f64) * 0.15).collect6 (); |
618 | 600 | let baseline_latencies6 : Vec<f64>6 = (0..100)6 .map6 (|i| 45.0 + (i as f64) * 0.1).collect6 (); |
619 | 600 | let stressed_latencies6 : Vec<f64>6 = (0..100)6 .map6 (|i| 75.0 + (i as f64) * 0.2).collect6 (); |
620 | 6 | let result = SaturationTestResult::new( |
621 | 6 | &config, |
622 | 6 | &baseline_throughputs, |
623 | 6 | &stressed_throughputs, |
624 | 6 | &baseline_latencies, |
625 | 6 | &stressed_latencies, |
626 | | ); |
627 | | |
628 | 6 | println!("Results:"); |
629 | 6 | println!( |
630 | 6 | " Baseline throughput: {:.1} tok/s", |
631 | | result.baseline_throughput |
632 | | ); |
633 | 6 | println!( |
634 | 6 | " Stressed throughput: {:.1} tok/s", |
635 | | result.stressed_throughput |
636 | | ); |
637 | 6 | println!( |
638 | 6 | " Throughput degradation: {:.1}%", |
639 | | result.throughput_degradation_pct |
640 | | ); |
641 | 6 | println!(" Baseline p99: {:.1}ms", result.baseline_p99_ms); |
642 | 6 | println!(" Stressed p99: {:.1}ms", result.stressed_p99_ms); |
643 | 6 | println!(" P99 increase: {:.1}%", result.p99_increase_pct); |
644 | 6 | println!(); |
645 | | |
646 | 6 | if result.passed { |
647 | 6 | println!("SATURATION TEST PASSED"); |
648 | 6 | } else { |
649 | 0 | println!("SATURATION TEST FAILED"); |
650 | 0 | for failure in &result.failure_reasons { |
651 | 0 | println!(" - {failure}"); |
652 | 0 | } |
653 | | } |
654 | | |
655 | 6 | if let Some(ref output_path2 ) = output { |
656 | 2 | if let Ok(json) = serde_json::to_string_pretty(&result) { |
657 | 2 | let _ = std::fs::write(output_path, json); |
658 | 2 | println!(); |
659 | 2 | println!("Results saved to: {output_path}"); |
660 | 2 | }0 |
661 | 4 | } |
662 | | |
663 | 6 | Ok(()) |
664 | 6 | } |
665 | | |
666 | | /// Compare two benchmark result files |
667 | 4 | pub fn run_bench_compare(file1: &str, file2: &str, threshold: f64) -> Result<()> { |
668 | | use crate::bench::{BenchmarkComparison, FullBenchmarkResult}; |
669 | | |
670 | 4 | println!("=== Benchmark Comparison ==="); |
671 | 4 | println!(); |
672 | 4 | println!("File 1: {file1}"); |
673 | 4 | println!("File 2: {file2}"); |
674 | 4 | println!("Significance threshold: {threshold}%"); |
675 | 4 | println!(); |
676 | | |
677 | | // Read and parse JSON files |
678 | 2 | let json1 = |
679 | 4 | std::fs::read_to_string(file1).map_err(|e| RealizarError::UnsupportedOperation { |
680 | 2 | operation: "read_benchmark".to_string(), |
681 | 2 | reason: format!("Failed to read {file1}: {e}"), |
682 | 2 | })?; |
683 | | |
684 | 1 | let json2 = |
685 | 2 | std::fs::read_to_string(file2).map_err(|e| RealizarError::UnsupportedOperation { |
686 | 1 | operation: "read_benchmark".to_string(), |
687 | 1 | reason: format!("Failed to read {file2}: {e}"), |
688 | 1 | })?; |
689 | | |
690 | 1 | let result10 = FullBenchmarkResult::from_json(&json1).map_err(|e| { |
691 | 1 | RealizarError::UnsupportedOperation { |
692 | 1 | operation: "parse_benchmark".to_string(), |
693 | 1 | reason: format!("Failed to parse {file1}: {e}"), |
694 | 1 | } |
695 | 1 | })?; |
696 | | |
697 | 0 | let result2 = FullBenchmarkResult::from_json(&json2).map_err(|e| { |
698 | 0 | RealizarError::UnsupportedOperation { |
699 | 0 | operation: "parse_benchmark".to_string(), |
700 | 0 | reason: format!("Failed to parse {file2}: {e}"), |
701 | 0 | } |
702 | 0 | })?; |
703 | | |
704 | 0 | let comparison = BenchmarkComparison::compare(&result1, &result2); |
705 | | |
706 | 0 | println!("Comparison Results:"); |
707 | 0 | println!(" TTFT p99: {:.1}% change", comparison.ttft_p99_change_pct); |
708 | 0 | println!( |
709 | 0 | " Throughput: {:.1}% change", |
710 | | comparison.throughput_change_pct |
711 | | ); |
712 | 0 | println!(" Memory: {:.1}% change", comparison.memory_change_pct); |
713 | 0 | println!(" Energy: {:.1}% change", comparison.energy_change_pct); |
714 | 0 | println!(); |
715 | 0 | println!("Winner: {}", comparison.winner); |
716 | 0 | println!("Significance (p-value): {:.4}", comparison.significance); |
717 | | |
718 | 0 | let ttft_significant = comparison.ttft_p99_change_pct.abs() > threshold; |
719 | 0 | let throughput_significant = comparison.throughput_change_pct.abs() > threshold; |
720 | | |
721 | 0 | println!(); |
722 | 0 | if ttft_significant || throughput_significant { |
723 | 0 | println!("Significant differences detected (>{threshold}%)"); |
724 | 0 | } else { |
725 | 0 | println!("No significant differences (threshold: {threshold}%)"); |
726 | 0 | } |
727 | | |
728 | 0 | Ok(()) |
729 | 4 | } |
730 | | |
731 | | /// Detect performance regressions between baseline and current |
732 | 4 | pub fn run_bench_regression(baseline_path: &str, current_path: &str, strict: bool) -> Result<()> { |
733 | | use crate::bench::{FullBenchmarkResult, RegressionResult}; |
734 | | |
735 | 4 | let threshold = if strict { 0.01 } else { 10.03 }; |
736 | | |
737 | 4 | println!("=== Regression Detection ==="); |
738 | 4 | println!(); |
739 | 4 | println!("Baseline: {baseline_path}"); |
740 | 4 | println!("Current: {current_path}"); |
741 | 4 | println!( |
742 | 4 | "Mode: {}", |
743 | 4 | if strict { |
744 | 1 | "strict (0%)" |
745 | | } else { |
746 | 3 | "normal (10%)" |
747 | | } |
748 | | ); |
749 | 4 | println!("Threshold: {threshold}%"); |
750 | 4 | println!(); |
751 | | |
752 | 4 | let baseline_json2 = std::fs::read_to_string(baseline_path).map_err(|e| {2 |
753 | 2 | RealizarError::UnsupportedOperation { |
754 | 2 | operation: "read_baseline".to_string(), |
755 | 2 | reason: format!("Failed to read {baseline_path}: {e}"), |
756 | 2 | } |
757 | 2 | })?; |
758 | | |
759 | 1 | let current_json = |
760 | 2 | std::fs::read_to_string(current_path).map_err(|e| RealizarError::UnsupportedOperation { |
761 | 1 | operation: "read_current".to_string(), |
762 | 1 | reason: format!("Failed to read {current_path}: {e}"), |
763 | 1 | })?; |
764 | | |
765 | 1 | let baseline0 = FullBenchmarkResult::from_json(&baseline_json).map_err(|e| { |
766 | 1 | RealizarError::UnsupportedOperation { |
767 | 1 | operation: "parse_baseline".to_string(), |
768 | 1 | reason: format!("Failed to parse {baseline_path}: {e}"), |
769 | 1 | } |
770 | 1 | })?; |
771 | | |
772 | 0 | let current = FullBenchmarkResult::from_json(¤t_json).map_err(|e| { |
773 | 0 | RealizarError::UnsupportedOperation { |
774 | 0 | operation: "parse_current".to_string(), |
775 | 0 | reason: format!("Failed to parse {current_path}: {e}"), |
776 | 0 | } |
777 | 0 | })?; |
778 | | |
779 | 0 | let regression = RegressionResult::check(&baseline, ¤t, threshold); |
780 | | |
781 | 0 | println!("Regression Analysis:"); |
782 | 0 | println!(" Threshold: {:.1}%", regression.threshold_pct); |
783 | 0 | println!(" Regression detected: {}", regression.regression_detected); |
784 | 0 | if !regression.regressed_metrics.is_empty() { |
785 | 0 | println!(" Regressed metrics:"); |
786 | 0 | for metric in ®ression.regressed_metrics { |
787 | 0 | println!(" - {metric}"); |
788 | 0 | } |
789 | 0 | } |
790 | 0 | println!(); |
791 | | |
792 | 0 | if regression.regression_detected { |
793 | 0 | println!("REGRESSION DETECTED"); |
794 | | // Note: Don't call process::exit here - let main handle it |
795 | 0 | return Err(RealizarError::UnsupportedOperation { |
796 | 0 | operation: "regression_check".to_string(), |
797 | 0 | reason: "Performance regression detected".to_string(), |
798 | 0 | }); |
799 | 0 | } |
800 | 0 | println!("NO REGRESSION DETECTED"); |
801 | | |
802 | 0 | Ok(()) |
803 | 4 | } |
804 | | |
805 | | /// Print info about realizar |
806 | 3 | pub fn print_info() { |
807 | 3 | println!("Realizar v{}", crate::VERSION); |
808 | 3 | println!("Pure Rust ML inference engine"); |
809 | 3 | println!(); |
810 | 3 | println!("Features:"); |
811 | 3 | println!(" - GGUF and Safetensors model formats"); |
812 | 3 | println!(" - Transformer inference (LLaMA architecture)"); |
813 | 3 | println!(" - BPE and SentencePiece tokenizers"); |
814 | 3 | println!(" - Greedy, top-k, and top-p sampling"); |
815 | 3 | println!(" - REST API for inference"); |
816 | 3 | } |
817 | | |
818 | | /// Load and display GGUF model information |
819 | 3 | pub fn load_gguf_model(file_data: &[u8]) -> Result<()> { |
820 | | use crate::gguf::GGUFModel; |
821 | | |
822 | 3 | println!("Parsing GGUF file..."); |
823 | 3 | let gguf0 = GGUFModel::from_bytes(file_data)?; |
824 | | |
825 | 0 | println!("Successfully parsed GGUF file"); |
826 | 0 | println!(); |
827 | 0 | println!("Model Information:"); |
828 | 0 | println!(" Version: {}", gguf.header.version); |
829 | 0 | println!(" Tensors: {}", gguf.header.tensor_count); |
830 | 0 | println!(" Metadata entries: {}", gguf.header.metadata_count); |
831 | 0 | println!(); |
832 | | |
833 | 0 | if !gguf.metadata.is_empty() { |
834 | 0 | println!("Metadata (first 5 entries):"); |
835 | 0 | for (key, _value) in gguf.metadata.iter().take(5) { |
836 | 0 | println!(" - {key}"); |
837 | 0 | } |
838 | 0 | if gguf.metadata.len() > 5 { |
839 | 0 | println!(" ... and {} more", gguf.metadata.len() - 5); |
840 | 0 | } |
841 | 0 | println!(); |
842 | 0 | } |
843 | | |
844 | 0 | if !gguf.tensors.is_empty() { |
845 | 0 | println!("Tensors (first 10):"); |
846 | 0 | for tensor in gguf.tensors.iter().take(10) { |
847 | 0 | let dims: Vec<String> = tensor |
848 | 0 | .dims |
849 | 0 | .iter() |
850 | 0 | .map(std::string::ToString::to_string) |
851 | 0 | .collect(); |
852 | 0 | println!( |
853 | 0 | " - {} [{}, qtype={}]", |
854 | 0 | tensor.name, |
855 | 0 | dims.join("x"), |
856 | 0 | tensor.qtype |
857 | 0 | ); |
858 | 0 | } |
859 | 0 | if gguf.tensors.len() > 10 { |
860 | 0 | println!(" ... and {} more", gguf.tensors.len() - 10); |
861 | 0 | } |
862 | 0 | println!(); |
863 | 0 | } |
864 | | |
865 | 0 | println!("Model loading infrastructure is ready!"); |
866 | 0 | println!(); |
867 | 0 | println!("Next steps to complete model loading:"); |
868 | 0 | println!(" 1. Extract ModelConfig from metadata (vocab_size, hidden_dim, etc.)"); |
869 | 0 | println!(" 2. Map tensor names to Model layers (see src/layers.rs docs)"); |
870 | 0 | println!(" 3. Load weights into each layer"); |
871 | 0 | println!(); |
872 | 0 | println!("See documentation: cargo doc --open"); |
873 | 0 | println!("Example: src/layers.rs module documentation"); |
874 | | |
875 | 0 | Ok(()) |
876 | 3 | } |
877 | | |
878 | | /// Load and display SafeTensors model information |
879 | 3 | pub fn load_safetensors_model(file_data: &[u8]) -> Result<()> { |
880 | | use crate::safetensors::SafetensorsModel; |
881 | | |
882 | 3 | println!("Parsing Safetensors file..."); |
883 | 3 | let safetensors0 = SafetensorsModel::from_bytes(file_data)?; |
884 | | |
885 | 0 | println!("Successfully parsed Safetensors file"); |
886 | 0 | println!(); |
887 | 0 | println!("Model Information:"); |
888 | 0 | println!(" Tensors: {}", safetensors.tensors.len()); |
889 | 0 | println!(" Data size: {} bytes", safetensors.data.len()); |
890 | 0 | println!(); |
891 | | |
892 | 0 | if !safetensors.tensors.is_empty() { |
893 | 0 | println!("Tensors (first 10):"); |
894 | 0 | for (name, tensor_info) in safetensors.tensors.iter().take(10) { |
895 | 0 | let shape: Vec<String> = tensor_info |
896 | 0 | .shape |
897 | 0 | .iter() |
898 | 0 | .map(std::string::ToString::to_string) |
899 | 0 | .collect(); |
900 | 0 | println!( |
901 | 0 | " - {} [{}, dtype={:?}]", |
902 | 0 | name, |
903 | 0 | shape.join("x"), |
904 | 0 | tensor_info.dtype |
905 | 0 | ); |
906 | 0 | } |
907 | 0 | if safetensors.tensors.len() > 10 { |
908 | 0 | println!(" ... and {} more", safetensors.tensors.len() - 10); |
909 | 0 | } |
910 | 0 | println!(); |
911 | 0 | } |
912 | | |
913 | 0 | println!("Model loading infrastructure is ready!"); |
914 | 0 | println!(); |
915 | 0 | println!("Next steps to complete model loading:"); |
916 | 0 | println!(" 1. Extract ModelConfig from tensor shapes"); |
917 | 0 | println!(" 2. Map tensor names to Model layers (see src/layers.rs docs)"); |
918 | 0 | println!(" 3. Load weights into each layer"); |
919 | 0 | println!(); |
920 | 0 | println!("See documentation: cargo doc --open"); |
921 | 0 | println!("Example: src/layers.rs module documentation"); |
922 | | |
923 | 0 | Ok(()) |
924 | 3 | } |
925 | | |
926 | | /// Load APR model file (aprender native format) |
927 | | /// |
928 | | /// Per spec §3.1: APR is the first-class format for classical ML models. |
929 | | /// |
930 | | /// # Arguments |
931 | | /// |
932 | | /// * `file_data` - APR file bytes |
933 | | /// |
934 | | /// # Errors |
935 | | /// |
936 | | /// Returns error if: |
937 | | /// - Magic bytes don't match (not "APRN") |
938 | | /// - Model type is unknown |
939 | | /// - File is corrupted |
940 | 29 | pub fn load_apr_model(file_data: &[u8]) -> Result<()> { |
941 | | use crate::format::{detect_format, ModelFormat}; |
942 | | use crate::model_loader::read_apr_model_type; |
943 | | |
944 | 29 | println!("Parsing APR file..."); |
945 | | |
946 | | // Verify format |
947 | 29 | let format25 = detect_format(file_data).map_err(|e| RealizarError::UnsupportedOperation { |
948 | 4 | operation: "detect_apr_format".to_string(), |
949 | 4 | reason: format!("Format detection failed: {e}"), |
950 | 4 | })?; |
951 | | |
952 | 25 | if format != ModelFormat::Apr { |
953 | 2 | return Err(RealizarError::UnsupportedOperation { |
954 | 2 | operation: "verify_apr_magic".to_string(), |
955 | 2 | reason: format!("Expected APR format, got {format}"), |
956 | 2 | }); |
957 | 23 | } |
958 | | |
959 | | // Extract model type |
960 | 23 | let model_type = read_apr_model_type(file_data).unwrap_or_else(|| "Unknown"1 .to_string1 ()); |
961 | | |
962 | 23 | println!("Successfully parsed APR file"); |
963 | 23 | println!(); |
964 | 23 | println!("Model Information:"); |
965 | 23 | println!(" Format: APR (Aprender Native)"); |
966 | 23 | println!(" Model Type: {model_type}"); |
967 | 23 | println!(" File Size: {} bytes", file_data.len()); |
968 | 23 | println!(); |
969 | | |
970 | | // APR header structure: APRN (4) + type_id (2) + version (2) = 8 bytes minimum |
971 | 23 | if file_data.len() >= 8 { |
972 | 23 | let version = u16::from_le_bytes([file_data[6], file_data[7]]); |
973 | 23 | println!(" Header Version: {version}"); |
974 | 23 | }0 |
975 | | |
976 | 23 | println!(); |
977 | 23 | println!("APR model ready for serving!"); |
978 | 23 | println!("Supported model types for inference:"); |
979 | 23 | println!(" - LogisticRegression, LinearRegression"); |
980 | 23 | println!(" - DecisionTree, RandomForest, GradientBoosting"); |
981 | 23 | println!(" - KNN, GaussianNB, LinearSVM"); |
982 | 23 | println!(); |
983 | 23 | println!("To serve this model, the serve API will auto-detect"); |
984 | 23 | println!("the model type and dispatch to the appropriate handler."); |
985 | | |
986 | 23 | Ok(()) |
987 | 29 | } |
988 | | |
989 | | /// Check if a model reference is a local file path |
990 | 65 | pub fn is_local_file_path(model_ref: &str) -> bool { |
991 | 65 | model_ref.starts_with("./") |
992 | 51 | || model_ref.starts_with('/') |
993 | 41 | || model_ref.ends_with(".gguf") |
994 | 32 | || model_ref.ends_with(".safetensors") |
995 | 25 | || model_ref.ends_with(".apr") |
996 | 65 | } |
997 | | |
998 | | /// Simple home directory resolution |
999 | 6 | pub fn home_dir() -> Option<std::path::PathBuf> { |
1000 | 6 | std::env::var_os("HOME").map(std::path::PathBuf::from) |
1001 | 6 | } |
1002 | | |
1003 | | /// Validate benchmark suite name |
1004 | 59 | pub fn validate_suite_name(suite_name: &str) -> bool { |
1005 | 323 | BENCHMARK_SUITES.iter()59 .any59 (|(name, _)| *name == suite_name) |
1006 | 59 | } |
1007 | | |
1008 | | // ============================================================================ |
1009 | | // Server Commands (extracted from main.rs for testability) |
1010 | | // WAPR-PERF-004: Gated behind "server" feature since depends on crate::api |
1011 | | // ============================================================================ |
1012 | | #[cfg(feature = "server")] |
1013 | | mod server_commands { |
1014 | | use super::{load_apr_model, load_safetensors_model, Result}; |
1015 | | |
1016 | | /// Result of preparing server state (returned by `prepare_serve_state`) |
1017 | | pub struct PreparedServer { |
1018 | | /// The prepared AppState for the server |
1019 | | pub state: crate::api::AppState, |
1020 | | /// Whether batch mode is enabled |
1021 | | pub batch_mode_enabled: bool, |
1022 | | /// Model type that was loaded |
1023 | | pub model_type: ModelType, |
1024 | | } |
1025 | | |
1026 | | impl std::fmt::Debug for PreparedServer { |
1027 | 5 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
1028 | 5 | f.debug_struct("PreparedServer") |
1029 | 5 | .field("batch_mode_enabled", &self.batch_mode_enabled) |
1030 | 5 | .field("model_type", &self.model_type) |
1031 | 5 | .finish_non_exhaustive() |
1032 | 5 | } |
1033 | | } |
1034 | | |
1035 | | /// Type of model being served |
1036 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
1037 | | pub enum ModelType { |
1038 | | /// GGUF quantized model |
1039 | | Gguf, |
1040 | | /// SafeTensors model |
1041 | | SafeTensors, |
1042 | | /// APR format model |
1043 | | Apr, |
1044 | | } |
1045 | | |
1046 | | impl std::fmt::Display for ModelType { |
1047 | 3 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
1048 | 3 | match self { |
1049 | 1 | ModelType::Gguf => write!(f, "GGUF"), |
1050 | 1 | ModelType::SafeTensors => write!(f, "SafeTensors"), |
1051 | 1 | ModelType::Apr => write!(f, "APR"), |
1052 | | } |
1053 | 3 | } |
1054 | | } |
1055 | | |
1056 | | /// Prepare server state by loading a model (GGUF/SafeTensors/APR) |
1057 | | /// |
1058 | | /// This function is extracted from `serve_model` for testability. |
1059 | | /// It handles model loading and AppState creation without starting the server. |
1060 | | /// |
1061 | | /// # Arguments |
1062 | | /// * `model_path` - Path to model file (.gguf, .safetensors, or .apr) |
1063 | | /// * `batch_mode` - Enable batch processing (requires 'gpu' feature) |
1064 | | /// * `force_gpu` - Force CUDA backend (requires 'cuda' feature) |
1065 | | /// |
1066 | | /// # Returns |
1067 | | /// A `PreparedServer` containing the AppState and configuration |
1068 | 11 | pub fn prepare_serve_state( |
1069 | 11 | model_path: &str, |
1070 | 11 | batch_mode: bool, |
1071 | 11 | force_gpu: bool, |
1072 | 11 | ) -> Result<PreparedServer> { |
1073 | | use crate::gguf::MappedGGUFModel; |
1074 | | |
1075 | 11 | println!("Loading model from: {model_path}"); |
1076 | 11 | if batch_mode { |
1077 | 1 | println!("Mode: BATCH (PARITY-093 M4 parity)"); |
1078 | 10 | } else { |
1079 | 10 | println!("Mode: SINGLE-REQUEST"); |
1080 | 10 | } |
1081 | 11 | if force_gpu { |
1082 | 1 | println!("GPU: FORCED (--gpu flag)"); |
1083 | 10 | } |
1084 | 11 | println!(); |
1085 | | |
1086 | 11 | if model_path.ends_with(".gguf") { |
1087 | | // Load GGUF model |
1088 | 4 | println!("Parsing GGUF file..."); |
1089 | 4 | let mapped0 = MappedGGUFModel::from_path(model_path).map_err(|e| { |
1090 | 4 | crate::error::RealizarError::UnsupportedOperation { |
1091 | 4 | operation: "load_gguf".to_string(), |
1092 | 4 | reason: format!("Failed to load GGUF: {e}"), |
1093 | 4 | } |
1094 | 4 | })?; |
1095 | | |
1096 | 0 | println!("Successfully loaded GGUF model"); |
1097 | 0 | println!(" Tensors: {}", mapped.model.tensors.len()); |
1098 | 0 | println!(" Metadata: {} entries", mapped.model.metadata.len()); |
1099 | 0 | println!(); |
1100 | | |
1101 | | // IMP-100: Use OwnedQuantizedModel with fused Q4_K ops (1.37x faster for single-token) |
1102 | 0 | println!("Creating quantized model (fused Q4_K ops)..."); |
1103 | 0 | let quantized_model = |
1104 | 0 | crate::gguf::OwnedQuantizedModel::from_mapped(&mapped).map_err(|e| { |
1105 | 0 | crate::error::RealizarError::UnsupportedOperation { |
1106 | 0 | operation: "create_quantized".to_string(), |
1107 | 0 | reason: format!("Failed to create quantized model: {e}"), |
1108 | 0 | } |
1109 | 0 | })?; |
1110 | | |
1111 | 0 | println!("Quantized model created successfully!"); |
1112 | 0 | println!(" Vocab size: {}", quantized_model.config.vocab_size); |
1113 | 0 | println!(" Hidden dim: {}", quantized_model.config.hidden_dim); |
1114 | 0 | println!(" Layers: {}", quantized_model.layers.len()); |
1115 | | |
1116 | | // Extract vocabulary from GGUF for proper token decoding |
1117 | 0 | let vocab = mapped.model.vocabulary().unwrap_or_else(|| { |
1118 | 0 | eprintln!(" Warning: No vocabulary in GGUF, using placeholder tokens"); |
1119 | 0 | (0..quantized_model.config.vocab_size) |
1120 | 0 | .map(|i| format!("token{i}")) |
1121 | 0 | .collect() |
1122 | 0 | }); |
1123 | 0 | println!(" Vocab loaded: {} tokens", vocab.len()); |
1124 | 0 | println!(); |
1125 | | |
1126 | | // PARITY-113: Enable CUDA backend via --gpu flag or REALIZAR_BACKEND environment variable |
1127 | | #[cfg(feature = "cuda")] |
1128 | | let use_cuda = force_gpu |
1129 | | || std::env::var("REALIZAR_BACKEND") |
1130 | | .map(|v| v.eq_ignore_ascii_case("cuda")) |
1131 | | .unwrap_or(false); |
1132 | | |
1133 | | #[cfg(not(feature = "cuda"))] |
1134 | 0 | let use_cuda = false; |
1135 | | |
1136 | | #[cfg(not(feature = "cuda"))] |
1137 | 0 | if force_gpu { |
1138 | 0 | eprintln!("Warning: --gpu flag requires 'cuda' feature. Falling back to CPU."); |
1139 | 0 | eprintln!("Build with: cargo build --features cuda"); |
1140 | 0 | eprintln!(); |
1141 | 0 | } |
1142 | | |
1143 | | // PARITY-093: Use cached model with batch support for M4 parity |
1144 | | // PAR-112-FIX: Use OwnedQuantizedModelCuda for true streaming support |
1145 | 0 | let state = if use_cuda && !batch_mode { |
1146 | | // PAR-112-FIX: Create OwnedQuantizedModelCuda for true streaming |
1147 | | // This enables generate_gpu_resident_streaming which streams tokens as generated |
1148 | | #[cfg(feature = "cuda")] |
1149 | | { |
1150 | | use crate::gguf::OwnedQuantizedModelCuda; |
1151 | | |
1152 | | let source = if force_gpu { |
1153 | | "--gpu flag" |
1154 | | } else { |
1155 | | "REALIZAR_BACKEND=cuda" |
1156 | | }; |
1157 | | println!("Creating CUDA model ({source})..."); |
1158 | | |
1159 | | let max_seq_len = 4096; // Support long sequences |
1160 | | let cuda_model = |
1161 | | OwnedQuantizedModelCuda::with_max_seq_len(quantized_model, 0, max_seq_len) |
1162 | | .map_err(|e| crate::error::RealizarError::UnsupportedOperation { |
1163 | | operation: "cuda_model_create".to_string(), |
1164 | | reason: format!("CUDA model creation failed: {e}"), |
1165 | | })?; |
1166 | | |
1167 | | println!(" CUDA model created on GPU: {}", cuda_model.device_name()); |
1168 | | println!(" Max sequence length: {}", max_seq_len); |
1169 | | println!(" TRUE STREAMING: enabled (PAR-112)"); |
1170 | | println!(); |
1171 | | |
1172 | | // Use with_cuda_model_and_vocab to enable true streaming path |
1173 | | crate::api::AppState::with_cuda_model_and_vocab(cuda_model, vocab)? |
1174 | | } |
1175 | | |
1176 | | #[cfg(not(feature = "cuda"))] |
1177 | | { |
1178 | | // This branch is unreachable since use_cuda is always false without cuda feature |
1179 | 0 | crate::api::AppState::with_quantized_model_and_vocab(quantized_model, vocab)? |
1180 | | } |
1181 | 0 | } else if batch_mode { |
1182 | | #[cfg(feature = "gpu")] |
1183 | | { |
1184 | | use crate::gguf::OwnedQuantizedModelCachedSync; |
1185 | | |
1186 | 0 | println!("Initializing batch inference mode (PARITY-093/094)..."); |
1187 | | |
1188 | | // Create cached model for scheduler reuse (10.6x speedup - IMP-112) |
1189 | 0 | let cached_model = OwnedQuantizedModelCachedSync::new(quantized_model); |
1190 | | |
1191 | | // PARITY-094: Warmup GPU cache for batch_generate_gpu |
1192 | | // This dequantizes FFN weights to GPU memory (~6GB for phi-2) |
1193 | 0 | println!(" Warming up GPU cache (dequantizing FFN weights)..."); |
1194 | 0 | match cached_model.warmup_gpu_cache() { |
1195 | 0 | Ok((memory_bytes, num_layers)) => { |
1196 | 0 | println!( |
1197 | 0 | " GPU cache ready: {:.2} GB ({} layers)", |
1198 | 0 | memory_bytes as f64 / 1e9, |
1199 | 0 | num_layers |
1200 | 0 | ); |
1201 | 0 | }, |
1202 | 0 | Err(e) => { |
1203 | 0 | eprintln!( |
1204 | 0 | " Warning: GPU cache warmup failed: {}. Falling back to CPU batch.", |
1205 | 0 | e |
1206 | 0 | ); |
1207 | 0 | }, |
1208 | | } |
1209 | | |
1210 | | // Create state first (this wraps model in Arc internally) |
1211 | 0 | let state = |
1212 | 0 | crate::api::AppState::with_cached_model_and_vocab(cached_model, vocab)?; |
1213 | | |
1214 | | // Get Arc'd model back for batch processor |
1215 | 0 | let cached_model_arc = state |
1216 | 0 | .cached_model() |
1217 | 0 | .expect("cached_model should exist") |
1218 | 0 | .clone(); |
1219 | | |
1220 | | // Configure batch processing (PARITY-095: aligned thresholds) |
1221 | 0 | let batch_config = crate::api::BatchConfig::default(); |
1222 | 0 | println!(" Batch window: {}ms", batch_config.window_ms); |
1223 | 0 | println!(" Min batch size: {}", batch_config.min_batch); |
1224 | 0 | println!(" Optimal batch: {}", batch_config.optimal_batch); |
1225 | 0 | println!(" Max batch size: {}", batch_config.max_batch); |
1226 | 0 | println!( |
1227 | 0 | " GPU threshold: {} (GPU GEMM for batch >= this)", |
1228 | | batch_config.gpu_threshold |
1229 | | ); |
1230 | | |
1231 | | // Spawn batch processor task |
1232 | 0 | let batch_tx = |
1233 | 0 | crate::api::spawn_batch_processor(cached_model_arc, batch_config.clone()); |
1234 | | |
1235 | 0 | println!(" Batch processor: RUNNING"); |
1236 | 0 | println!(); |
1237 | | |
1238 | | // Add batch support to state |
1239 | 0 | state.with_batch_config(batch_tx, batch_config) |
1240 | | } |
1241 | | |
1242 | | #[cfg(not(feature = "gpu"))] |
1243 | | { |
1244 | | eprintln!( |
1245 | | "Warning: --batch requires 'gpu' feature. Falling back to single-request mode." |
1246 | | ); |
1247 | | crate::api::AppState::with_quantized_model_and_vocab(quantized_model, vocab)? |
1248 | | } |
1249 | | } else { |
1250 | | // CPU mode: Use quantized model for serving (fused CPU ops are faster for m=1) |
1251 | 0 | crate::api::AppState::with_quantized_model_and_vocab(quantized_model, vocab)? |
1252 | | }; |
1253 | | |
1254 | 0 | Ok(PreparedServer { |
1255 | 0 | state, |
1256 | 0 | batch_mode_enabled: batch_mode, |
1257 | 0 | model_type: ModelType::Gguf, |
1258 | 0 | }) |
1259 | 7 | } else if model_path.ends_with(".safetensors") { |
1260 | 2 | let file_data0 = std::fs::read(model_path).map_err(|e| { |
1261 | 2 | crate::error::RealizarError::UnsupportedOperation { |
1262 | 2 | operation: "read_model_file".to_string(), |
1263 | 2 | reason: format!("Failed to read {model_path}: {e}"), |
1264 | 2 | } |
1265 | 2 | })?; |
1266 | 0 | load_safetensors_model(&file_data)?; |
1267 | | // SafeTensors models use demo state (full serving requires GGUF conversion) |
1268 | | Ok(PreparedServer { |
1269 | 0 | state: crate::api::AppState::demo()?, |
1270 | | batch_mode_enabled: false, |
1271 | 0 | model_type: ModelType::SafeTensors, |
1272 | | }) |
1273 | 5 | } else if model_path.ends_with(".apr") { |
1274 | 2 | let file_data0 = std::fs::read(model_path).map_err(|e| { |
1275 | 2 | crate::error::RealizarError::UnsupportedOperation { |
1276 | 2 | operation: "read_model_file".to_string(), |
1277 | 2 | reason: format!("Failed to read {model_path}: {e}"), |
1278 | 2 | } |
1279 | 2 | })?; |
1280 | 0 | load_apr_model(&file_data)?; |
1281 | | // APR models use demo state (full serving requires GGUF conversion) |
1282 | | Ok(PreparedServer { |
1283 | 0 | state: crate::api::AppState::demo()?, |
1284 | | batch_mode_enabled: false, |
1285 | 0 | model_type: ModelType::Apr, |
1286 | | }) |
1287 | | } else { |
1288 | 3 | Err(crate::error::RealizarError::UnsupportedOperation { |
1289 | 3 | operation: "detect_model_type".to_string(), |
1290 | 3 | reason: "Unsupported file extension. Expected .gguf, .safetensors, or .apr" |
1291 | 3 | .to_string(), |
1292 | 3 | }) |
1293 | | } |
1294 | 11 | } |
1295 | | |
1296 | | /// Serve a GGUF/SafeTensors/APR model via HTTP API |
1297 | | /// |
1298 | | /// This function was extracted from main.rs (PAR-112-FIX) to enable: |
1299 | | /// 1. Unit testing of server initialization logic |
1300 | | /// 2. Coverage measurement (main.rs was at 3.66%) |
1301 | | /// 3. Reuse from other entry points |
1302 | | /// |
1303 | | /// # Arguments |
1304 | | /// * `host` - Host to bind to (e.g., "0.0.0.0") |
1305 | | /// * `port` - Port to listen on |
1306 | | /// * `model_path` - Path to model file (.gguf, .safetensors, or .apr) |
1307 | | /// * `batch_mode` - Enable batch processing (requires 'gpu' feature) |
1308 | | /// * `force_gpu` - Force CUDA backend (requires 'cuda' feature) |
1309 | 5 | pub async fn serve_model( |
1310 | 5 | host: &str, |
1311 | 5 | port: u16, |
1312 | 5 | model_path: &str, |
1313 | 5 | batch_mode: bool, |
1314 | 5 | force_gpu: bool, |
1315 | 5 | ) -> Result<()> { |
1316 | | // Prepare server state (testable) |
1317 | 5 | let prepared0 = prepare_serve_state(model_path, batch_mode, force_gpu)?; |
1318 | | |
1319 | | // Create router |
1320 | 0 | let app = crate::api::create_router(prepared.state); |
1321 | | |
1322 | | // Parse and validate address |
1323 | 0 | let addr: std::net::SocketAddr = format!("{host}:{port}").parse().map_err(|e| { |
1324 | 0 | crate::error::RealizarError::InvalidShape { |
1325 | 0 | reason: format!("Invalid address: {e}"), |
1326 | 0 | } |
1327 | 0 | })?; |
1328 | | |
1329 | | // Print server info |
1330 | 0 | println!("Server listening on http://{addr}"); |
1331 | 0 | println!(); |
1332 | 0 | println!("Endpoints:"); |
1333 | 0 | println!(" GET /health - Health check"); |
1334 | 0 | println!(" POST /v1/completions - OpenAI-compatible completions"); |
1335 | 0 | if prepared.batch_mode_enabled { |
1336 | 0 | println!(" POST /v1/batch/completions - GPU batch completions (PARITY-022)"); |
1337 | 0 | println!(" POST /v1/gpu/warmup - Warmup GPU cache"); |
1338 | 0 | println!(" GET /v1/gpu/status - GPU status"); |
1339 | 0 | } |
1340 | 0 | println!(" POST /generate - Generate text (Q4_K fused)"); |
1341 | 0 | println!(); |
1342 | | |
1343 | 0 | if prepared.batch_mode_enabled { |
1344 | 0 | println!("M4 Parity Target: 192 tok/s at concurrency >= 4"); |
1345 | 0 | println!("Benchmark with: wrk -t4 -c4 -d30s http://{addr}/v1/completions"); |
1346 | 0 | println!(); |
1347 | 0 | } |
1348 | | |
1349 | | // Bind and serve |
1350 | 0 | let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { |
1351 | 0 | crate::error::RealizarError::UnsupportedOperation { |
1352 | 0 | operation: "bind".to_string(), |
1353 | 0 | reason: format!("Failed to bind: {e}"), |
1354 | 0 | } |
1355 | 0 | })?; |
1356 | | |
1357 | 0 | axum::serve(listener, app).await.map_err(|e| { |
1358 | 0 | crate::error::RealizarError::UnsupportedOperation { |
1359 | 0 | operation: "serve".to_string(), |
1360 | 0 | reason: format!("Server error: {e}"), |
1361 | 0 | } |
1362 | 0 | })?; |
1363 | | |
1364 | 0 | Ok(()) |
1365 | 5 | } |
1366 | | |
1367 | | /// Start a demo inference server (no model required) |
1368 | | /// |
1369 | | /// This is useful for testing the API without loading a real model. |
1370 | 0 | pub async fn serve_demo(host: &str, port: u16) -> Result<()> { |
1371 | | use std::net::SocketAddr; |
1372 | | |
1373 | | println!("Starting Realizar inference server (demo mode)..."); |
1374 | | |
1375 | | let state = crate::api::AppState::demo()?; |
1376 | | let app = crate::api::create_router(state); |
1377 | | |
1378 | | let addr: SocketAddr = format!("{host}:{port}").parse().map_err(|e| { |
1379 | | crate::error::RealizarError::InvalidShape { |
1380 | | reason: format!("Invalid address: {e}"), |
1381 | | } |
1382 | | })?; |
1383 | | |
1384 | | println!("Server listening on http://{addr}"); |
1385 | | println!(); |
1386 | | println!("Endpoints:"); |
1387 | | println!(" GET /health - Health check"); |
1388 | | println!(" POST /tokenize - Tokenize text"); |
1389 | | println!(" POST /generate - Generate text"); |
1390 | | println!(); |
1391 | | println!("Example:"); |
1392 | | println!(" curl http://{addr}/health"); |
1393 | | println!(); |
1394 | | |
1395 | | let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { |
1396 | | crate::error::RealizarError::InvalidShape { |
1397 | | reason: format!("Failed to bind: {e}"), |
1398 | | } |
1399 | | })?; |
1400 | | |
1401 | | axum::serve(listener, app).await.map_err(|e| { |
1402 | | crate::error::RealizarError::InvalidShape { |
1403 | | reason: format!("Server error: {e}"), |
1404 | | } |
1405 | | })?; |
1406 | | |
1407 | | Ok(()) |
1408 | | } |
1409 | | } // mod server_commands |
1410 | | |
1411 | | #[cfg(feature = "server")] |
1412 | | pub use server_commands::*; |
1413 | | |
1414 | | // Tests extracted to tests.rs (PMAT-802) |
1415 | | #[cfg(test)] |
1416 | | #[path = "tests.rs"] |
1417 | | mod cli_tests; |