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