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