1use std::path::PathBuf;
2use std::time::{Duration, Instant};
3
4use tokio::sync::{mpsc, watch};
5
6use crate::backend::server::{SpawnServerRequest, spawn_server};
7use crate::models::{
8 BenchTuneConfig, BenchTuneMetrics, BenchTuneMode, BenchTuneParamValue, BenchTuneResult,
9 BenchTuneStatus, DiscoveredModel, ModelSettings, ServerMode,
10};
11
12const HEALTH_CHECK_ITERATIONS: u32 = 120;
14const HEALTH_CHECK_INTERVAL_MS: u64 = 500;
15const HEALTH_CHECK_LOG_INTERVAL: u32 = 10;
16const REQUEST_TIMEOUT_SECS: u64 = 120;
17
18async fn wait_for_server_ready(
21 host: &str,
22 port: u16,
23 log_tx: &mpsc::Sender<String>,
24) -> bool {
25 for i in 0..HEALTH_CHECK_ITERATIONS {
26 if crate::backend::server::check_health(host, port).await {
27 return true;
28 }
29 if i % HEALTH_CHECK_LOG_INTERVAL == 0 && i > 0 {
30 let _ = log_tx
31 .send(format!(
32 " ... still waiting ({:.0}s)...",
33 i as f32 * (HEALTH_CHECK_INTERVAL_MS as f32 / 1000.0)
34 ))
35 .await;
36 }
37 tokio::time::sleep(Duration::from_millis(HEALTH_CHECK_INTERVAL_MS)).await;
38 }
39 false
40}
41
42struct BenchAccumulator {
43 params: BenchTuneParamValue,
44 total_prompt_tokens: u64,
45 total_generation_tokens: u64,
46 total_prompt_time: Duration,
47 total_generation_time: Duration,
48 total_time: Duration,
49 prompt_processing_times: Vec<u128>,
50 outputs: Vec<String>,
51 per_iteration_metrics: Vec<BenchTuneMetrics>,
52 base_settings: Option<ModelSettings>,
53}
54
55fn build_bench_result(acc: BenchAccumulator) -> BenchTuneResult {
56 let BenchAccumulator {
57 params,
58 total_prompt_tokens,
59 total_generation_tokens,
60 total_prompt_time,
61 total_generation_time,
62 total_time,
63 prompt_processing_times,
64 outputs,
65 per_iteration_metrics,
66 base_settings,
67 } = acc;
68 let prompt_tps = if total_prompt_time.as_secs_f64() > 0.0 {
69 (total_prompt_tokens as f64) / total_prompt_time.as_secs_f64()
70 } else {
71 0.0
72 };
73
74 let generation_tps = if total_generation_time.as_secs_f64() > 0.0 {
75 (total_generation_tokens as f64) / total_generation_time.as_secs_f64()
76 } else {
77 0.0
78 };
79
80 let combined_tps = if total_time.as_secs_f64() > 0.0 {
81 ((total_prompt_tokens + total_generation_tokens) as f64) / total_time.as_secs_f64()
82 } else {
83 0.0
84 };
85
86 let avg_latency_per_token = if total_generation_tokens > 0 {
87 total_generation_time.as_millis() as f64 / (total_generation_tokens as f64)
88 } else {
89 0.0
90 };
91
92 let avg_prompt_processing_time = if !prompt_processing_times.is_empty() {
93 prompt_processing_times.iter().sum::<u128>() as f64 / prompt_processing_times.len() as f64
94 } else {
95 0.0
96 };
97
98 BenchTuneResult {
99 params,
100 metrics: BenchTuneMetrics {
101 prompt_tps,
102 generation_tps,
103 combined_tps,
104 latency_per_token: avg_latency_per_token,
105 prompt_processing_time: avg_prompt_processing_time,
106 },
107 outputs,
108 per_iteration_metrics,
109 base_settings,
110 server_command: None,
111 }
112}
113
114pub struct BenchTuneRequest<'a> {
115 pub main_config: &'a crate::config::Config,
116 pub config: &'a BenchTuneConfig,
117 pub model: &'a DiscoveredModel,
118 pub settings: &'a ModelSettings,
119 pub progress_tx: mpsc::Sender<BenchTuneStatus>,
120 pub log_tx: mpsc::Sender<String>,
121 pub cancel_rx: &'a mut watch::Receiver<bool>,
122}
123
124pub async fn run_bench_tune(
126 req: BenchTuneRequest<'_>,
127) -> Result<Vec<BenchTuneResult>, Box<dyn std::error::Error + Send + Sync>> {
128 let BenchTuneRequest {
129 main_config,
130 config,
131 model,
132 settings,
133 progress_tx,
134 log_tx,
135 cancel_rx,
136 } = req;
137 let start_time = Instant::now();
138 let total_tests = config.get_total_tests_count();
139
140 if total_tests > 500 {
142 let _ = log_tx
143 .send(format!(
144 "WARNING: Benchmark will run {} combinations. This may take a long time.",
145 total_tests
146 ))
147 .await;
148 }
149
150 let combinations = config.generate_combinations();
152
153 let mut results = Vec::new();
155 let mut failed_tests: Vec<(usize, String)> = Vec::new();
156
157 let mut settings = settings.clone();
159 if let Some(kwargs) = &config.chat_template_kwargs {
160 settings.chat_template_kwargs = Some(kwargs.clone());
161 }
162
163 let client = reqwest::Client::builder()
165 .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
166 .build()?;
167
168 if config.bench_mode == BenchTuneMode::RuntimeOnly {
170 let (exit_tx, _exit_rx) = tokio::sync::mpsc::channel(1);
172 let (server_handle, server_command) = spawn_server(SpawnServerRequest {
173 config: main_config,
174 model: Some(model),
175 settings: &settings,
176 log_tx: log_tx.clone(),
177 progress_tx: None,
178 server_mode: ServerMode::Normal,
179 router_max_models: 1,
180 exit_tx,
181 })
182 .await?;
183
184 let host = if server_handle.host == "0.0.0.0" {
185 "127.0.0.1"
186 } else {
187 &server_handle.host
188 };
189
190 if *cancel_rx.borrow() {
192 let _ = crate::backend::server::kill_server(server_handle).await;
193 let elapsed = start_time.elapsed();
194 progress_tx
195 .send(BenchTuneStatus::Cancelled {
196 total_tests,
197 successful_tests: results.len(),
198 failed_tests: failed_tests.len(),
199 elapsed,
200 })
201 .await?;
202 return Ok(results);
203 }
204 if !wait_for_server_ready(host, server_handle.port, &log_tx).await {
205 let _ = crate::backend::server::kill_server(server_handle).await;
206 return Err("Server failed to become healthy".into());
207 }
208
209 let server_port = server_handle.port;
210 let server_host = host.to_string();
211
212 for (idx, combination) in combinations.iter().enumerate() {
213 if *cancel_rx.borrow() {
215 let _ = crate::backend::server::kill_server(server_handle).await;
216 let elapsed = start_time.elapsed();
217 progress_tx
218 .send(BenchTuneStatus::Cancelled {
219 total_tests,
220 successful_tests: results.len(),
221 failed_tests: failed_tests.len(),
222 elapsed,
223 })
224 .await?;
225 return Ok(results);
226 }
227
228 let progress = (idx as f32 / total_tests as f32) * 100.0;
229 progress_tx
230 .send(BenchTuneStatus::Running {
231 current: idx + 1,
232 total: total_tests,
233 progress,
234 current_params: combination.clone(),
235 })
236 .await?;
237
238 let result = run_bench_tune_runtime_only(RuntimeOnlyCtx {
239 params: combination,
240 settings: &settings,
241 num_iterations: config.num_iterations,
242 prompt: config.prompt.clone(),
243 server_host: &server_host,
244 server_port,
245 log_tx: log_tx.clone(),
246 config,
247 client: &client,
248 server_command: &server_command,
249 })
250 .await;
251
252 match result {
253 Ok(test_result) => results.push(test_result),
254 Err(e) => {
255 failed_tests.push((idx + 1, e.to_string()));
256 let _ = log_tx
257 .send(format!(
258 "Benchmark test {}/{} failed: {}",
259 idx + 1,
260 total_tests,
261 e
262 ))
263 .await;
264 }
265 }
266 }
267
268 let _ = crate::backend::server::kill_server(server_handle).await;
269 } else {
270 for (idx, combination) in combinations.iter().enumerate() {
272 if *cancel_rx.borrow() {
274 let elapsed = start_time.elapsed();
275 progress_tx
276 .send(BenchTuneStatus::Cancelled {
277 total_tests,
278 successful_tests: results.len(),
279 failed_tests: failed_tests.len(),
280 elapsed,
281 })
282 .await?;
283 return Ok(results);
284 }
285
286 let progress = (idx as f32 / total_tests as f32) * 100.0;
287 progress_tx
288 .send(BenchTuneStatus::Running {
289 current: idx + 1,
290 total: total_tests,
291 progress,
292 current_params: combination.clone(),
293 })
294 .await?;
295
296 let result = run_bench_tune_single_test(SingleTestCtx {
297 main_config,
298 params: combination,
299 model,
300 base_settings: &settings,
301 num_iterations: config.num_iterations,
302 prompt: config.prompt.clone(),
303 log_tx: log_tx.clone(),
304 config,
305 client: &client,
306 })
307 .await;
308
309 match result {
310 Ok(test_result) => results.push(test_result),
311 Err(e) => {
312 failed_tests.push((idx + 1, e.to_string()));
313 let _ = log_tx
314 .send(format!(
315 "Benchmark test {}/{} failed: {}",
316 idx + 1,
317 total_tests,
318 e
319 ))
320 .await;
321 }
322 }
323 }
324 }
325
326 results.sort_by(|a, b| {
328 b.metrics
329 .combined_tps
330 .partial_cmp(&a.metrics.combined_tps)
331 .unwrap_or(std::cmp::Ordering::Equal)
332 });
333
334 let elapsed = start_time.elapsed();
335 let successful_tests = results.len();
336 let failed_count = failed_tests.len();
337
338 if failed_count > 0 {
340 progress_tx
341 .send(BenchTuneStatus::PartiallyCompleted {
342 total_tests,
343 successful_tests,
344 failed_tests: failed_count,
345 elapsed,
346 })
347 .await?;
348 } else {
349 progress_tx
350 .send(BenchTuneStatus::Completed {
351 total_tests,
352 successful_tests,
353 elapsed,
354 })
355 .await?;
356 }
357
358 Ok(results)
359}
360
361struct IterationLoopCtx<'a> {
363 prompt: &'a str,
364 host: &'a str,
365 port: u16,
366 params: &'a BenchTuneParamValue,
367 num_iterations: u32,
368 config: &'a BenchTuneConfig,
369 client: &'a reqwest::Client,
370 log_tx: mpsc::Sender<String>,
371 log_prefix: &'a str,
372}
373
374async fn run_iteration_loop(
376 ctx: IterationLoopCtx<'_>,
377) -> Result<BenchTuneResult, Box<dyn std::error::Error + Send + Sync>> {
378 let IterationLoopCtx {
379 prompt,
380 host,
381 port,
382 params,
383 num_iterations,
384 config,
385 client,
386 log_tx,
387 log_prefix,
388 } = ctx;
389 let mut total_prompt_tokens = 0u64;
390 let mut total_generation_tokens = 0u64;
391 let mut total_prompt_time = Duration::ZERO;
392 let mut total_generation_time = Duration::ZERO;
393 let mut total_time = Duration::ZERO;
394 let mut prompt_processing_times = Vec::new();
395 let mut outputs = Vec::new();
396 let mut per_iteration_metrics = Vec::new();
397
398 let _ = log_tx
399 .send(format!(
400 "Running {} inference iterations {}...",
401 num_iterations, log_prefix
402 ))
403 .await;
404
405 for i in 0..num_iterations {
406 let result = send_inference_request(prompt, host, port, params, config, client).await;
407
408 match result {
409 Ok(res) => {
410 total_prompt_tokens += res.prompt_tokens;
411 total_generation_tokens += res.generation_tokens;
412 total_prompt_time += res.prompt_time;
413 total_generation_time += res.generation_time;
414 total_time += res.total_time;
415 prompt_processing_times.push(res.prompt_processing_time);
416 outputs.push(res.content.clone());
417
418 let iter_prompt_tps = if res.prompt_time.as_secs_f64() > 0.0 {
419 res.prompt_tokens as f64 / res.prompt_time.as_secs_f64()
420 } else {
421 0.0
422 };
423 let iter_gen_tps = if res.generation_time.as_secs_f64() > 0.0 {
424 res.generation_tokens as f64 / res.generation_time.as_secs_f64()
425 } else {
426 0.0
427 };
428 let iter_combined_tps = if res.total_time.as_secs_f64() > 0.0 {
429 ((res.prompt_tokens + res.generation_tokens) as f64)
430 / res.total_time.as_secs_f64()
431 } else {
432 0.0
433 };
434 let iter_latency = if res.generation_tokens > 0 {
435 res.generation_time.as_millis() as f64 / res.generation_tokens as f64
436 } else {
437 0.0
438 };
439
440 per_iteration_metrics.push(BenchTuneMetrics {
441 prompt_tps: iter_prompt_tps,
442 generation_tps: iter_gen_tps,
443 combined_tps: iter_combined_tps,
444 latency_per_token: iter_latency,
445 prompt_processing_time: res.prompt_processing_time as f64,
446 });
447
448 if num_iterations > 1 {
449 let _ = log_tx
450 .send(format!(
451 " Iteration {}/{}: {:.2} gen t/s",
452 i + 1,
453 num_iterations,
454 iter_gen_tps
455 ))
456 .await;
457 }
458
459 let _ = log_tx
460 .send(format!(
461 "--- Generated Output (Iter {}) ---\n{}\n----------------------------------",
462 i + 1,
463 res.content
464 ))
465 .await;
466 }
467 Err(e) => {
468 let _ = log_tx
469 .send(format!(
470 " Iteration {}/{} FAILED: {}",
471 i + 1,
472 num_iterations,
473 e
474 ))
475 .await;
476 if i == 0 {
477 return Err(format!("Inference failed: {}", e).into());
478 }
479 }
480 }
481 }
482
483 Ok(build_bench_result(BenchAccumulator {
484 params: params.clone(),
485 total_prompt_tokens,
486 total_generation_tokens,
487 total_prompt_time,
488 total_generation_time,
489 total_time,
490 prompt_processing_times,
491 outputs,
492 per_iteration_metrics,
493 base_settings: None,
494 }))
495}
496
497struct RuntimeOnlyCtx<'a> {
498 params: &'a BenchTuneParamValue,
499 settings: &'a ModelSettings,
500 num_iterations: u32,
501 prompt: String,
502 server_host: &'a str,
503 server_port: u16,
504 log_tx: mpsc::Sender<String>,
505 config: &'a BenchTuneConfig,
506 client: &'a reqwest::Client,
507 server_command: &'a str,
508}
509
510async fn run_bench_tune_runtime_only(
512 ctx: RuntimeOnlyCtx<'_>,
513) -> Result<BenchTuneResult, Box<dyn std::error::Error + Send + Sync>> {
514 let RuntimeOnlyCtx {
515 params,
516 settings,
517 num_iterations,
518 prompt,
519 server_host,
520 server_port,
521 log_tx,
522 config,
523 client,
524 server_command,
525 } = ctx;
526 let loop_fut = run_iteration_loop(IterationLoopCtx {
527 prompt: &prompt,
528 host: server_host,
529 port: server_port,
530 params,
531 num_iterations,
532 config,
533 client,
534 log_tx,
535 log_prefix: "(runtime-only mode)",
536 });
537 let result = tokio::time::timeout(config.test_timeout, loop_fut).await;
538 let result = match result {
539 Ok(inner) => inner,
540 Err(_) => return Err(format!("Test timed out after {:?}", config.test_timeout).into()),
541 };
542 result.map(|mut r| {
543 r.base_settings = Some(settings.clone());
544 r.server_command = Some(server_command.to_string());
545 r
546 })
547}
548
549struct SingleTestCtx<'a> {
550 main_config: &'a crate::config::Config,
551 params: &'a BenchTuneParamValue,
552 model: &'a DiscoveredModel,
553 base_settings: &'a ModelSettings,
554 num_iterations: u32,
555 prompt: String,
556 log_tx: mpsc::Sender<String>,
557 config: &'a BenchTuneConfig,
558 client: &'a reqwest::Client,
559}
560
561async fn run_bench_tune_single_test(
563 ctx: SingleTestCtx<'_>,
564) -> Result<BenchTuneResult, Box<dyn std::error::Error + Send + Sync>> {
565 let SingleTestCtx {
566 main_config,
567 params,
568 model,
569 base_settings,
570 num_iterations,
571 prompt,
572 log_tx,
573 config,
574 client,
575 } = ctx;
576 let mut settings = base_settings.clone();
578
579 if let Some(temperature) = params.temperature {
581 settings.temperature = temperature as f32;
582 }
583 if let Some(top_p) = params.top_p {
584 settings.top_p = top_p as f32;
585 }
586 if let Some(top_k) = params.top_k {
587 settings.top_k = top_k as i32;
588 }
589 if let Some(repeat_penalty) = params.repeat_penalty {
590 settings.repeat_penalty = repeat_penalty as f32;
591 }
592 if let Some(flash_attn) = params.flash_attn {
593 settings.flash_attn = flash_attn;
594 }
595 if let Some(threads) = params.threads {
596 settings.threads = threads;
597 settings.threads_batch = threads; }
599 if let Some(batch_size) = params.batch_size {
600 settings.batch_size = batch_size;
601 settings.ubatch_size = batch_size;
602 }
603 if let Some(expert_count) = params.expert_count {
604 settings.expert_count = expert_count;
605 }
606 if let Some(ref spec_type) = params.spec_type {
607 settings.spec_type = if spec_type == "Off" {
608 String::new()
609 } else {
610 spec_type.clone()
611 };
612 }
613 if let Some(draft_tokens) = params.draft_tokens {
614 settings.draft_tokens = draft_tokens;
615 }
616
617 let (exit_tx, _exit_rx) = tokio::sync::mpsc::channel(1);
619 let (server_handle, command) = spawn_server(SpawnServerRequest {
620 config: main_config,
621 model: Some(model),
622 settings: &settings,
623 log_tx: log_tx.clone(),
624 progress_tx: None,
625 server_mode: ServerMode::Normal,
626 router_max_models: 1,
627 exit_tx,
628 })
629 .await?;
630 let host = if server_handle.host == "0.0.0.0" {
631 "127.0.0.1"
632 } else {
633 &server_handle.host
634 };
635
636 let _ = log_tx
637 .send(format!(
638 "Waiting for server on {}:{}...",
639 host, server_handle.port
640 ))
641 .await;
642
643 if !wait_for_server_ready(host, server_handle.port, &log_tx).await {
644 let _ = log_tx
645 .send("Error: Server health check timed out".to_string())
646 .await;
647 let _ = crate::backend::server::kill_server(server_handle).await;
648 return Err("Server failed to become healthy".into());
649 }
650
651 let loop_fut = run_iteration_loop(IterationLoopCtx {
652 prompt: &prompt,
653 host,
654 port: server_handle.port,
655 params,
656 num_iterations,
657 config,
658 client,
659 log_tx,
660 log_prefix: "",
661 });
662 let result = tokio::time::timeout(config.test_timeout, loop_fut).await;
663 let result = match result {
664 Ok(inner) => inner,
665 Err(_) => {
666 let _ = crate::backend::server::kill_server(server_handle).await;
667 return Err(format!("Test timed out after {:?}", config.test_timeout).into());
668 }
669 };
670
671 let _ = crate::backend::server::kill_server(server_handle).await;
672 tokio::time::sleep(Duration::from_secs(1)).await;
673
674 result.map(|mut r| {
675 r.base_settings = Some(base_settings.clone());
676 r.server_command = Some(command);
677 r
678 })
679}
680
681async fn send_inference_request(
683 prompt: &str,
684 host: &str,
685 port: u16,
686 params: &BenchTuneParamValue,
687 config: &BenchTuneConfig,
688 client: &reqwest::Client,
689) -> Result<InferenceResult, Box<dyn std::error::Error + Send + Sync>> {
690 let mut body = serde_json::json!({
692 "prompt": prompt,
693 "n_predict": config.n_predict,
694 "stream": false
695 });
696
697 if let Some(temperature) = params.temperature {
698 body["temperature"] = serde_json::json!(temperature);
699 }
700 if let Some(top_p) = params.top_p {
701 body["top_p"] = serde_json::json!(top_p);
702 }
703 if let Some(top_k) = params.top_k {
704 body["top_k"] = serde_json::json!(top_k);
705 }
706 if let Some(repeat_penalty) = params.repeat_penalty {
707 body["repeat_penalty"] = serde_json::json!(repeat_penalty);
708 }
709
710 let url = format!("http://{}:{}/completion", host, port);
711 let start = Instant::now();
712 let resp = client.post(url).json(&body).send().await?;
713
714 if !resp.status().is_success() {
715 let status = resp.status();
716 let body = resp.text().await.unwrap_or_else(|_| "no body".to_string());
717 return Err(format!("Server returned error {}: {}", status, body).into());
718 }
719
720 let total_time = start.elapsed();
721 let json: serde_json::Value = resp.json().await?;
722
723 let prompt_tokens = json["tokens_evaluated"]
725 .as_u64()
726 .or_else(|| json["prompt_n"].as_u64())
727 .unwrap_or(0);
728
729 let generation_tokens = json["tokens_predicted"]
730 .as_u64()
731 .or_else(|| json["predicted_n"].as_u64())
732 .unwrap_or(0);
733
734 let timings = &json["timings"];
735 let prompt_time_ms = timings["prompt_ms"]
736 .as_f64()
737 .or_else(|| timings["prompt_eval_ms"].as_f64())
738 .unwrap_or(0.0);
739
740 let generation_time_ms = timings["predicted_ms"]
741 .as_f64()
742 .or_else(|| timings["eval_ms"].as_f64())
743 .unwrap_or(0.0);
744
745 Ok(InferenceResult {
746 prompt_tokens,
747 generation_tokens,
748 prompt_time: Duration::from_millis(prompt_time_ms as u64),
749 generation_time: Duration::from_millis(generation_time_ms as u64),
750 total_time,
751 prompt_processing_time: prompt_time_ms as u128,
752 content: json["content"].as_str().unwrap_or("").to_string(),
753 })
754}
755
756pub async fn save_results(
758 results: &[BenchTuneResult],
759 output_dir: &PathBuf,
760 config: &BenchTuneConfig,
761) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
762 std::fs::create_dir_all(output_dir)?;
764
765 let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
767 let filename = format!("benchmark_{}.md", timestamp);
768 let filepath = output_dir.join(filename);
769
770 let mut md = String::new();
771 md.push_str("# LLM Benchmark Results\n\n");
772 md.push_str(&format!(
773 "Generated on: {}\n\n",
774 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
775 ));
776
777 md.push_str("| Temp | Top-P | Top-K | RepPen | FA | Threads | Batch | Exp | Spec | Draft | Prompt t/s | Gen t/s | Latency (ms) | First Tok (ms) |\n");
778 md.push_str("|------|-------|-------|--------|----|---------|-------|-----|------|-------|------------|---------|--------------|----------------|\n");
779
780 for r in results {
781 let temp = r
782 .params
783 .temperature
784 .map(|v| format!("{:.2}", v))
785 .unwrap_or_else(|| "-".to_string());
786 let top_p = r
787 .params
788 .top_p
789 .map(|v| format!("{:.2}", v))
790 .unwrap_or_else(|| "-".to_string());
791 let top_k = r
792 .params
793 .top_k
794 .map(|v| v.to_string())
795 .unwrap_or_else(|| "-".to_string());
796 let rep_pen = r
797 .params
798 .repeat_penalty
799 .map(|v| format!("{:.2}", v))
800 .unwrap_or_else(|| "-".to_string());
801 let fa = r
802 .params
803 .flash_attn
804 .map(|v| if v { "ON" } else { "OFF" })
805 .unwrap_or("-");
806 let threads = r
807 .params
808 .threads
809 .map(|v| v.to_string())
810 .unwrap_or_else(|| "-".to_string());
811 let batch = r
812 .params
813 .batch_size
814 .map(|v| v.to_string())
815 .unwrap_or_else(|| "-".to_string());
816 let exp = r
817 .params
818 .expert_count
819 .map(|v| v.to_string())
820 .unwrap_or_else(|| "-".to_string());
821
822 let spec = r
823 .params
824 .spec_type
825 .as_ref()
826 .map(|s| {
827 if s.is_empty() {
828 "-".to_string()
829 } else {
830 s.clone()
831 }
832 })
833 .unwrap_or_else(|| "-".to_string());
834 let draft = r
835 .params
836 .draft_tokens
837 .map(|v| v.to_string())
838 .unwrap_or_else(|| "-".to_string());
839
840 md.push_str(&format!(
841 "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {:.2} | {:.2} | {:.2} | {:.2} |\n",
842 temp,
843 top_p,
844 top_k,
845 rep_pen,
846 fa,
847 threads,
848 batch,
849 exp,
850 spec,
851 draft,
852 r.metrics.prompt_tps,
853 r.metrics.generation_tps,
854 r.metrics.latency_per_token,
855 r.metrics.prompt_processing_time
856 ));
857 }
858
859 tokio::fs::write(&filepath, md).await?;
860
861 let json_filename = format!("benchmark_{}.json", timestamp);
863 let json_filepath = output_dir.join(&json_filename);
864 let json_content = serde_json::to_string_pretty(&results)?;
865 tokio::fs::write(&json_filepath, json_content).await?;
866
867 let yaml_filename = format!("benchmark_{}.yaml", timestamp);
869 let yaml_filepath = output_dir.join(&yaml_filename);
870 let yaml_content = serde_yml::to_string(&results)?;
871 tokio::fs::write(&yaml_filepath, yaml_content).await?;
872
873 let html_filename = format!("benchmark_{}.html", timestamp);
875 let html_filepath = output_dir.join(&html_filename);
876 let html_content = generate_html_report(results, config);
877 tokio::fs::write(&html_filepath, html_content).await?;
878
879 Ok(())
880}
881
882fn generate_html_report(results: &[BenchTuneResult], config: &BenchTuneConfig) -> String {
883 use chrono::Local;
884
885 let total_tests = results.len();
886 let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
887
888 let model_info = results.first().and_then(|r| {
890 r.base_settings.as_ref().map(|s| {
891 let model_name = if config.model_path.file_name().is_some() {
892 config
893 .model_path
894 .file_name()
895 .unwrap()
896 .to_string_lossy()
897 .to_string()
898 } else {
899 config.model_path.display().to_string()
900 };
901 let file_size_mb = std::fs::metadata(&config.model_path)
902 .ok()
903 .map(|m| m.len() / 1_048_576)
904 .unwrap_or(0);
905 (model_name, file_size_mb, s.clone())
906 })
907 });
908
909 struct ResolvedParams {
911 temperature: f64,
912 top_p: f64,
913 top_k: i64,
914 repeat_penalty: f64,
915 flash_attn: bool,
916 threads: u32,
917 batch_size: u32,
918 expert_count: i32,
919 spec_type: String,
920 draft_tokens: u32,
921 }
922
923 fn resolve_params(
924 params: &BenchTuneParamValue,
925 base: &crate::models::ModelSettings,
926 ) -> ResolvedParams {
927 ResolvedParams {
928 temperature: params.temperature.unwrap_or(base.temperature as f64),
929 top_p: params.top_p.unwrap_or(base.top_p as f64),
930 top_k: params.top_k.unwrap_or(base.top_k as i64),
931 repeat_penalty: params.repeat_penalty.unwrap_or(base.repeat_penalty as f64),
932 flash_attn: params.flash_attn.unwrap_or(base.flash_attn),
933 threads: params.threads.unwrap_or(base.threads),
934 batch_size: params.batch_size.unwrap_or(base.batch_size),
935 expert_count: params.expert_count.unwrap_or(base.expert_count),
936 spec_type: params
937 .spec_type
938 .clone()
939 .unwrap_or_else(|| base.spec_type.clone()),
940 draft_tokens: params.draft_tokens.unwrap_or(base.draft_tokens),
941 }
942 }
943
944 fn mean(vals: &[f64]) -> f64 {
946 if vals.is_empty() {
947 return 0.0;
948 }
949 vals.iter().sum::<f64>() / vals.len() as f64
950 }
951 fn median(vals: &[f64]) -> f64 {
952 if vals.is_empty() {
953 return 0.0;
954 }
955 let mut sorted = vals.to_vec();
956 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
957 let mid = sorted.len() / 2;
958 if sorted.len().is_multiple_of(2) {
959 (sorted[mid - 1] + sorted[mid]) / 2.0
960 } else {
961 sorted[mid]
962 }
963 }
964 fn std_dev(vals: &[f64], avg: f64) -> f64 {
965 if vals.len() <= 1 {
966 return 0.0;
967 }
968 let variance =
969 vals.iter().map(|v| (v - avg).powi(2)).sum::<f64>() / (vals.len() - 1) as f64;
970 variance.sqrt()
971 }
972 fn min_val(vals: &[f64]) -> f64 {
973 vals.iter().cloned().fold(f64::INFINITY, f64::min)
974 }
975 fn max_val(vals: &[f64]) -> f64 {
976 vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
977 }
978
979 let gen_tps: Vec<f64> = results.iter().map(|r| r.metrics.generation_tps).collect();
980 let prompt_tps: Vec<f64> = results.iter().map(|r| r.metrics.prompt_tps).collect();
981 let latency: Vec<f64> = results
982 .iter()
983 .map(|r| r.metrics.latency_per_token)
984 .collect();
985 let first_token: Vec<f64> = results
986 .iter()
987 .map(|r| r.metrics.prompt_processing_time)
988 .collect();
989
990 let gen_tps_sorted = gen_tps.clone();
991 let latency_sorted = latency.clone();
992
993 let avg_gen_tps = mean(&gen_tps);
994 let avg_prompt_tps = mean(&prompt_tps);
995 let avg_latency = mean(&latency);
996 let avg_first_token = mean(&first_token);
997 let _avg_combined_tps = mean(
998 &results
999 .iter()
1000 .map(|r| r.metrics.combined_tps)
1001 .collect::<Vec<f64>>(),
1002 );
1003
1004 let gen_std = std_dev(&gen_tps, avg_gen_tps);
1005 let prompt_std = std_dev(&prompt_tps, avg_prompt_tps);
1006 let lat_std = std_dev(&latency, avg_latency);
1007 let ft_std = std_dev(&first_token, avg_first_token);
1008
1009 let best_idx = results
1010 .iter()
1011 .enumerate()
1012 .max_by(|a, b| {
1013 a.1.metrics
1014 .generation_tps
1015 .partial_cmp(&b.1.metrics.generation_tps)
1016 .unwrap_or(std::cmp::Ordering::Equal)
1017 })
1018 .map(|(i, _)| i);
1019 let best_gen_tps = if !gen_tps.is_empty() {
1020 max_val(&gen_tps)
1021 } else {
1022 0.0
1023 };
1024 let best_prompt_tps = if !prompt_tps.is_empty() {
1025 max_val(&prompt_tps)
1026 } else {
1027 0.0
1028 };
1029 let best_latency = if !latency.is_empty() {
1030 min_val(&latency)
1031 } else {
1032 0.0
1033 };
1034 let best_first_token = if !first_token.is_empty() {
1035 min_val(&first_token)
1036 } else {
1037 0.0
1038 };
1039 let min_gen_tps = min_val(&gen_tps);
1040 let min_prompt_tps = min_val(&prompt_tps);
1041 let min_latency = min_val(&latency);
1042 let min_first_token = min_val(&first_token);
1043
1044 let param_names = [
1046 ("temperature", "Temperature"),
1047 ("top_p", "Top-P"),
1048 ("top_k", "Top-K"),
1049 ("repeat_penalty", "Repeat Penalty"),
1050 ("flash_attn", "Flash Attention"),
1051 ("threads", "Threads"),
1052 ("batch_size", "Batch Size"),
1053 ("expert_count", "Experts"),
1054 ];
1055
1056 let impact_data: Vec<(String, String, f64)> = param_names
1057 .iter()
1058 .filter_map(|(key, label)| {
1059 let values: Vec<f64> = results
1060 .iter()
1061 .filter_map(|r| {
1062 let base = r.base_settings.as_ref()?;
1063 let rp = resolve_params(&r.params, base);
1064 Some(match *key {
1065 "temperature" => rp.temperature,
1066 "top_p" => rp.top_p,
1067 "top_k" => rp.top_k as f64,
1068 "repeat_penalty" => rp.repeat_penalty,
1069 "flash_attn" => {
1070 if rp.flash_attn {
1071 1.0
1072 } else {
1073 0.0
1074 }
1075 }
1076 "threads" => rp.threads as f64,
1077 "batch_size" => rp.batch_size as f64,
1078 "expert_count" => rp.expert_count as f64,
1079 _ => return None,
1080 })
1081 })
1082 .collect();
1083
1084 let mut groups: std::collections::HashMap<String, Vec<f64>> =
1086 std::collections::HashMap::new();
1087 for (r, v) in results.iter().zip(values.iter()) {
1088 let key_str = if *key == "flash_attn" {
1089 if *v > 0.5 {
1090 "ON".to_string()
1091 } else {
1092 "OFF".to_string()
1093 }
1094 } else {
1095 format!("{:.2}", v)
1096 };
1097 groups
1098 .entry(key_str)
1099 .or_default()
1100 .push(r.metrics.generation_tps);
1101 }
1102
1103 if groups.len() <= 1 {
1104 return None;
1105 } let group_means: Vec<f64> = groups.values().map(|vals| mean(vals)).collect();
1108 let spread = max_val(&group_means) - min_val(&group_means);
1109 Some((label.to_string(), format!("{:.1}", spread), spread))
1110 })
1111 .collect();
1112
1113 let mut impact_sorted = impact_data.clone();
1115 impact_sorted.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
1116
1117 let consistency_data: Vec<f64> = results
1119 .iter()
1120 .map(|r| {
1121 if r.per_iteration_metrics.len() <= 1 {
1122 return 1.0; }
1124 let iter_gen_tps: Vec<f64> = r
1125 .per_iteration_metrics
1126 .iter()
1127 .map(|m| m.generation_tps)
1128 .collect();
1129 let iter_mean = mean(&iter_gen_tps);
1130 if iter_mean == 0.0 {
1131 return 1.0;
1132 }
1133 let iter_std = std_dev(&iter_gen_tps, iter_mean);
1134 let cv = iter_std / iter_mean; (1.0 - (cv * 5.0)).clamp(0.0, 1.0)
1138 })
1139 .collect();
1140
1141 let top_n = std::cmp::min(20, total_tests);
1143 let top_indices: Vec<(usize, usize)> = (0..total_tests)
1144 .map(|i| (i, results[i].metrics.generation_tps))
1145 .enumerate()
1146 .take(top_n)
1147 .map(|(rank, (idx, _))| (rank + 1, idx))
1148 .collect();
1149
1150 let top_labels: Vec<String> = top_indices
1151 .iter()
1152 .map(|(_rank, idx)| {
1153 let base = results[*idx].base_settings.as_ref().unwrap();
1154 let rp = resolve_params(&results[*idx].params, base);
1155 format!("T={:.2} TP={:.2}", rp.temperature, rp.top_p)
1156 })
1157 .collect();
1158 let top_gen_tps: Vec<f64> = top_indices
1159 .iter()
1160 .map(|(_, idx)| results[*idx].metrics.generation_tps)
1161 .collect();
1162
1163 let scatter_gen_tps: Vec<f64> = results.iter().map(|r| r.metrics.generation_tps).collect();
1165 let scatter_latency: Vec<f64> = results
1166 .iter()
1167 .map(|r| r.metrics.latency_per_token)
1168 .collect();
1169 let scatter_first_token: Vec<f64> = results
1170 .iter()
1171 .map(|r| r.metrics.prompt_processing_time)
1172 .collect();
1173
1174 let param_headers: Vec<String> = vec![
1175 "Temp".to_string(),
1176 "Top-P".to_string(),
1177 "Top-K".to_string(),
1178 "RepPen".to_string(),
1179 "FA".to_string(),
1180 "Threads".to_string(),
1181 "Batch".to_string(),
1182 "Exp".to_string(),
1183 "Spec".to_string(),
1184 "Draft".to_string(),
1185 ];
1186 let param_vals: Vec<Vec<String>> = results
1187 .iter()
1188 .map(|r| {
1189 let base = r.base_settings.as_ref().unwrap();
1190 let rp = resolve_params(&r.params, base);
1191 vec![
1192 format!("{:.2}", rp.temperature),
1193 format!("{:.2}", rp.top_p),
1194 rp.top_k.to_string(),
1195 format!("{:.2}", rp.repeat_penalty),
1196 if rp.flash_attn {
1197 "ON".to_string()
1198 } else {
1199 "OFF".to_string()
1200 },
1201 rp.threads.to_string(),
1202 rp.batch_size.to_string(),
1203 rp.expert_count.to_string(),
1204 if rp.spec_type.is_empty() {
1205 "-".to_string()
1206 } else {
1207 rp.spec_type.clone()
1208 },
1209 rp.draft_tokens.to_string(),
1210 ]
1211 })
1212 .collect();
1213
1214 let metrics_data: Vec<serde_json::Value> = results
1216 .iter()
1217 .enumerate()
1218 .map(|(i, r)| {
1219 let base = r.base_settings.as_ref().unwrap();
1220 let rp = resolve_params(&r.params, base);
1221 serde_json::json!({
1222 "idx": i,
1223 "temp": rp.temperature,
1224 "top_p": rp.top_p,
1225 "top_k": rp.top_k,
1226 "repeat_penalty": rp.repeat_penalty,
1227 "flash_attn": rp.flash_attn,
1228 "threads": rp.threads,
1229 "batch_size": rp.batch_size,
1230 "expert_count": rp.expert_count,
1231 "spec_type": rp.spec_type,
1232 "draft_tokens": rp.draft_tokens,
1233 "prompt_tps": r.metrics.prompt_tps,
1234 "generation_tps": r.metrics.generation_tps,
1235 "combined_tps": r.metrics.combined_tps,
1236 "latency_per_token": r.metrics.latency_per_token,
1237 "prompt_processing_time": r.metrics.prompt_processing_time,
1238 "consistency": consistency_data[i],
1239 "outputs": r.outputs,
1240 "per_iteration_metrics": r.per_iteration_metrics.iter().map(|m| {
1241 serde_json::json!({
1242 "prompt_tps": m.prompt_tps,
1243 "generation_tps": m.generation_tps,
1244 "combined_tps": m.combined_tps,
1245 "latency_per_token": m.latency_per_token,
1246 "prompt_processing_time": m.prompt_processing_time,
1247 })
1248 }).collect::<Vec<_>>(),
1249 "server_command": r.server_command.as_deref().unwrap_or("-"),
1250 })
1251 })
1252 .collect();
1253
1254 let scatter_data_json = serde_json::to_string(
1256 &scatter_gen_tps
1257 .iter()
1258 .zip(scatter_latency.iter())
1259 .zip(scatter_first_token.iter())
1260 .map(|((g, l), f)| {
1261 let mut s = String::from("{x:");
1262 s.push_str(&format!("{:.2}", g));
1263 s.push_str(",y:");
1264 s.push_str(&format!("{:.2}", l));
1265 s.push_str(",ft:");
1266 s.push_str(&format!("{:.2}", f));
1267 s.push('}');
1268 s
1269 })
1270 .collect::<Vec<_>>(),
1271 )
1272 .unwrap();
1273 let scatter_data2_json = serde_json::to_string(
1274 &scatter_gen_tps
1275 .iter()
1276 .zip(scatter_first_token.iter())
1277 .map(|(g, f)| {
1278 let mut s = String::from("{x:");
1279 s.push_str(&format!("{:.2}", g));
1280 s.push_str(",y:");
1281 s.push_str(&format!("{:.2}", f));
1282 s.push_str(",lat:");
1283 s.push_str(&format!("{:.2}", min_val(&latency)));
1284 s.push('}');
1285 s
1286 })
1287 .collect::<Vec<_>>(),
1288 )
1289 .unwrap();
1290
1291 let model_meta_json = model_info.as_ref().map(|(name, _size, settings)| {
1293 serde_json::json!({
1294 "model_name": name,
1295 "context_length": settings.context_length,
1296 "threads": settings.threads,
1297 "temperature": settings.temperature,
1298 "top_p": settings.top_p,
1299 "top_k": settings.top_k,
1300 "repeat_penalty": settings.repeat_penalty,
1301 "flash_attn": settings.flash_attn,
1302 "kv_cache_offload": settings.kv_cache_offload,
1303 "mlock": settings.mlock,
1304 "system_prompt": settings.system_prompt,
1305 })
1306 });
1307
1308 let _impact_json = serde_json::to_string(&impact_sorted).unwrap();
1310
1311 let column_defs_json = serde_json::to_string(&vec![
1313 ("col-rank", "#", true),
1314 ("col-temp", "Temp", true),
1315 ("col-top-p", "Top-P", true),
1316 ("col-top-k", "Top-K", true),
1317 ("col-rep-pen", "RepPen", true),
1318 ("col-fa", "FA", true),
1319 ("col-threads", "Threads", true),
1320 ("col-batch", "Batch", true),
1321 ("col-exp", "Exp", true),
1322 ("col-spec", "Spec", true),
1323 ("col-draft", "Draft", true),
1324 ("col-gen-tps", "Gen t/s", true),
1325 ("col-prompt-tps", "Prompt t/s", true),
1326 ("col-latency", "Latency", true),
1327 ("col-first-token", "First Tok", true),
1328 ("col-combined", "Combined", true),
1329 ("col-consistency", "Consistency", true),
1330 ])
1331 .unwrap();
1332
1333 let csv_header = "Rank,Temp,Top-P,Top-K,RepPen,FA,Threads,Batch,Exp,Spec,Draft,Gen t/s,Prompt t/s,Latency (ms),First Tok (ms),Combined,Consistency";
1335 let csv_rows: Vec<String> = (0..total_tests)
1336 .map(|i| {
1337 let d = &metrics_data[i];
1338 let rank = i + 1;
1339 let spec = d
1340 .get("spec_type")
1341 .map(|v| v.as_str().unwrap_or("-"))
1342 .unwrap_or("-")
1343 .to_string();
1344 let draft = d
1345 .get("draft_tokens")
1346 .map(|v| v.as_u64().unwrap_or(0).to_string())
1347 .unwrap_or("-".to_string());
1348 format!(
1349 "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{:.1}",
1350 rank,
1351 d["temp"].as_f64().unwrap_or(0.0),
1352 d["top_p"].as_f64().unwrap_or(0.0),
1353 d["top_k"].as_i64().unwrap_or(0),
1354 d["repeat_penalty"].as_f64().unwrap_or(0.0),
1355 if d["flash_attn"].as_bool().unwrap_or(false) {
1356 "ON"
1357 } else {
1358 "OFF"
1359 },
1360 d["threads"].as_u64().unwrap_or(0),
1361 d["batch_size"].as_u64().unwrap_or(0),
1362 d["expert_count"].as_i64().unwrap_or(0),
1363 spec,
1364 draft,
1365 d["generation_tps"].as_f64().unwrap_or(0.0),
1366 d["prompt_tps"].as_f64().unwrap_or(0.0),
1367 d["latency_per_token"].as_f64().unwrap_or(0.0),
1368 d["prompt_processing_time"].as_f64().unwrap_or(0.0),
1369 d["combined_tps"].as_f64().unwrap_or(0.0),
1370 d["consistency"].as_f64().unwrap_or(1.0)
1371 )
1372 })
1373 .collect();
1374 let csv_content = format!("{}\n{}", csv_header, csv_rows.join("\n"));
1375 let csv_b64 = base64_encode(&csv_content);
1376
1377 let metrics_json = serde_json::to_string(&metrics_data).unwrap();
1378 let param_headers_json = serde_json::to_string(¶m_headers).unwrap();
1379 let param_vals_json = serde_json::to_string(¶m_vals).unwrap();
1380 let top_labels_json = serde_json::to_string(&top_labels).unwrap();
1381 let top_gen_tps_json = serde_json::to_string(&top_gen_tps).unwrap();
1382
1383 let model_meta_html = model_info
1385 .as_ref()
1386 .map(|(name, _size, s)| {
1387 format!(
1388 r#"
1389<div class="meta-section">
1390<h2>Model & Configuration</h2>
1391<div class="meta-grid">
1392<div class="meta-item"><div class="ml">Model</div><div class="mv">{}</div></div>
1393<div class="meta-item"><div class="ml">Context</div><div class="mv">{}</div></div>
1394<div class="meta-item"><div class="ml">Threads</div><div class="mv">{}</div></div>
1395<div class="meta-item"><div class="ml">Flash Attention</div><div class="mv">{}</div></div>
1396<div class="meta-item"><div class="ml">KV Cache Offload</div><div class="mv">{}</div></div>
1397<div class="meta-item"><div class="ml">MLOCK</div><div class="mv">{}</div></div>
1398<div class="meta-item"><div class="ml">Prompt</div><div class="mv meta-prompt">{}</div></div>
1399</div>
1400</div>"#,
1401 escape_html(name),
1402 s.context_length,
1403 s.threads,
1404 if s.flash_attn { "ON" } else { "OFF" },
1405 if s.kv_cache_offload { "ON" } else { "OFF" },
1406 if s.mlock { "ON" } else { "OFF" },
1407 escape_html(&s.system_prompt.chars().take(100).collect::<String>())
1408 )
1409 })
1410 .unwrap_or_default();
1411
1412 let winner_html = best_idx.and_then(|idx| {
1414 let r = &results[idx];
1415 let base = r.base_settings.as_ref()?;
1416 let rp = resolve_params(&r.params, base);
1417 let m = &r.metrics;
1418 Some(format!(r#"
1419<div class="winner-section">
1420<div class="winner-icon">🏆</div>
1421<div class="winner-content">
1422<div class="winner-title">Best Configuration</div>
1423<div class="winner-metrics">
1424<div class="winner-metric"><span class="wm-label">Gen t/s</span><span class="wm-value" style="color:#3fb950;font-size:1.8em;">{:.2}</span></div>
1425<div class="winner-metric"><span class="wm-label">Prompt t/s</span><span class="wm-value">{:.2}</span></div>
1426<div class="winner-metric"><span class="wm-label">Latency</span><span class="wm-value">{:.2}ms</span></div>
1427<div class="winner-metric"><span class="wm-label">First Token</span><span class="wm-value">{:.0}ms</span></div>
1428</div>
1429<div class="winner-params">Temp: {:.2} · Top-P: {:.2} · Top-K: {} · RepPen: {:.2} · FA: {} · Threads: {} · Batch: {} · Exp: {} · Spec: {} · Draft: {}</div>
1430</div>
1431</div>"#,
1432 m.generation_tps, m.prompt_tps, m.latency_per_token, m.prompt_processing_time,
1433 rp.temperature, rp.top_p, rp.top_k, rp.repeat_penalty,
1434 if rp.flash_attn { "ON" } else { "OFF" }, rp.threads,
1435 rp.batch_size, rp.expert_count,
1436 if rp.spec_type.is_empty() { "Off".to_string() } else { rp.spec_type.clone() }, rp.draft_tokens
1437 ))
1438 }).unwrap_or_default();
1439
1440 let impact_html = if !impact_sorted.is_empty() {
1442 let max_impact = impact_sorted[0].2;
1443 let rows: String = impact_sorted
1444 .iter()
1445 .map(|(label, spread, value)| {
1446 let bar_width = if max_impact > 0.0 {
1447 (value / max_impact * 100.0) as i32
1448 } else {
1449 0
1450 };
1451 let bar_color = if *value > max_impact * 0.7 {
1452 "#f85149"
1453 } else if *value > max_impact * 0.4 {
1454 "#d29922"
1455 } else {
1456 "#3fb950"
1457 };
1458 format!(
1459 r#"<div class="impact-row">
1460<div class="impact-label">{}</div>
1461<div class="impact-bar-bg"><div class="impact-bar-fill" style="width:{}%;background:{}"></div></div>
1462<div class="impact-value">{}</div>
1463</div>"#,
1464 label, bar_width, bar_color, spread
1465 )
1466 })
1467 .collect();
1468 format!(
1469 r#"
1470<div class="impact-section">
1471<h2>Parameter Impact Analysis</h2>
1472<p class="impact-desc">Larger spread in generation throughput between parameter values indicates greater impact on performance.</p>
1473{}
1474</div>"#,
1475 rows
1476 )
1477 } else {
1478 r#"<div class="impact-section"><h2>Parameter Impact Analysis</h2><p class="impact-desc">All parameters were held constant — no impact data available.</p></div>"#.to_string()
1479 };
1480
1481 let empty_html = if total_tests == 0 {
1483 r#"<div class="empty-state">
1484<div class="empty-icon">📊</div>
1485<div class="empty-title">No Results</div>
1486<div class="empty-text">Run a benchmark tuning test to generate results here.</div>
1487</div>"#
1488 } else {
1489 ""
1490 };
1491
1492 let html = include_str!("benchmark_report.html");
1493
1494 html.replace("__TIMESTAMP__", ×tamp)
1496 .replace("__TOTAL_TESTS__", &total_tests.to_string())
1497 .replace("__EMPTY_STATE__", empty_html)
1498 .replace("__MODEL_META__", &model_meta_html)
1499 .replace("__WINNER__", &winner_html)
1500 .replace("__AVG_GEN_TPS__", &format!("{:.1}", avg_gen_tps))
1501 .replace(
1502 "__MED_GEN_TPS__",
1503 &format!("{:.1}", median(&gen_tps_sorted)),
1504 )
1505 .replace("__GEN_STD__", &format!("{:.1}", gen_std))
1506 .replace("__MIN_GEN__", &format!("{:.1}", min_gen_tps))
1507 .replace("__MAX_GEN__", &format!("{:.1}", best_gen_tps))
1508 .replace("__AVG_PROMPT_TPS__", &format!("{:.1}", avg_prompt_tps))
1509 .replace("__MED_PROMPT_TPS__", &format!("{:.1}", median(&prompt_tps)))
1510 .replace("__PROMPT_STD__", &format!("{:.1}", prompt_std))
1511 .replace("__MIN_PROMPT__", &format!("{:.1}", min_prompt_tps))
1512 .replace("__MAX_PROMPT__", &format!("{:.1}", best_prompt_tps))
1513 .replace("__AVG_LATENCY__", &format!("{:.1}ms", avg_latency))
1514 .replace(
1515 "__MED_LATENCY__",
1516 &format!("{:.1}ms", median(&latency_sorted)),
1517 )
1518 .replace("__LAT_STD__", &format!("{:.1}", lat_std))
1519 .replace("__MIN_LAT__", &format!("{:.1}", min_latency))
1520 .replace("__MAX_LAT__", &format!("{:.1}", best_latency))
1521 .replace("__AVG_FT__", &format!("{:.0}ms", avg_first_token))
1522 .replace("__MED_FT__", &format!("{:.0}ms", median(&first_token)))
1523 .replace("__FT_STD__", &format!("{:.0}", ft_std))
1524 .replace("__MIN_FT__", &format!("{:.0}ms", min_first_token))
1525 .replace("__MAX_FT__", &format!("{:.0}ms", best_first_token))
1526 .replace("__BEST_GEN__", &format!("{:.1}", best_gen_tps))
1527 .replace("__TOP_N__", &top_n.to_string())
1528 .replace("__IMPACT_HTML__", &impact_html)
1529 .replace("__METRICS_JSON__", &metrics_json)
1530 .replace("__PARAM_HEADERS_JSON__", ¶m_headers_json)
1531 .replace("__PARAM_VALS_JSON__", ¶m_vals_json)
1532 .replace("__TOP_LABELS_JSON__", &top_labels_json)
1533 .replace("__TOP_GEN_TPS_JSON__", &top_gen_tps_json)
1534 .replace("__SCATTER_DATA_JSON__", &scatter_data_json)
1535 .replace("__SCATTER_DATA2_JSON__", &scatter_data2_json)
1536 .replace("__COLUMN_DEFS_JSON__", &column_defs_json)
1537 .replace("__CSV_B64__", &csv_b64)
1538 .replace(
1539 "__MODEL_META_JSON__",
1540 &serde_json::to_string(&model_meta_json).unwrap(),
1541 )
1542}
1543
1544fn escape_html(s: &str) -> String {
1546 s.replace('&', "&")
1547 .replace('<', "<")
1548 .replace('>', ">")
1549 .replace('"', """)
1550}
1551
1552fn base64_encode(input: &str) -> String {
1554 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1555 let bytes = input.as_bytes();
1556 let mut result = String::new();
1557 for chunk in bytes.chunks(3) {
1558 let b0 = chunk[0] as u32;
1559 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
1560 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
1561 let triple = (b0 << 16) | (b1 << 8) | b2;
1562 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1563 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1564 if chunk.len() > 1 {
1565 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
1566 } else {
1567 result.push('=');
1568 }
1569 if chunk.len() > 2 {
1570 result.push(CHARS[(triple & 0x3F) as usize] as char);
1571 } else {
1572 result.push('=');
1573 }
1574 }
1575 result
1576}
1577
1578struct InferenceResult {
1580 prompt_tokens: u64,
1581 generation_tokens: u64,
1582 prompt_time: Duration,
1583 generation_time: Duration,
1584 total_time: Duration,
1585 prompt_processing_time: u128, content: String,
1587}