Skip to main content

llm_manager/backend/
benchmark.rs

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
12/// Benchmark tuning constants
13const 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
100/// Run a benchmark tuning test with multiple parameter combinations
101pub 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    // Warn on large runs
117    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    // Generate all parameter combinations
127    let combinations = config.generate_combinations();
128
129    // Results storage
130    let mut results = Vec::new();
131    let mut failed_tests: Vec<(usize, String)> = Vec::new();
132
133    // Apply chat_template_kwargs from config to settings
134    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    // Create a shared HTTP client for all inference requests
140    let client = reqwest::Client::builder()
141        .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
142        .build()?;
143
144    // If runtime-only mode, send params in request body (no server restarts)
145    if config.bench_mode == BenchTuneMode::RuntimeOnly {
146        // Spawn a single server for all runtime-only iterations
147        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        // Wait for server to be ready
167        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            // Check cancellation before each test
200            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        // Full mode: spawn a new server for each parameter combination
257        for (idx, combination) in combinations.iter().enumerate() {
258            // Check cancellation before each test
259            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    // Sort results by combined_tps (descending)
313    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    // Final progress update - distinguish between full success and partial success
325    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
347/// Run inference iterations and accumulate metrics into a BenchTuneResult.
348struct 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
360/// Shared by both runtime-only and full benchmark modes.
361async 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
496/// Run benchmark in runtime-only mode: sends params in /completion request body, no server restarts
497async 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
547/// Run a single benchmark tuning test with specific parameters
548async 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    // Create settings with test parameters
563    let mut settings = base_settings.clone();
564
565    // Apply test parameters
566    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; // Usually keep them equal for benchmarks
584    }
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    // Spawn server with test parameters
604    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    // Wait for server to be ready
617    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
685/// Send an inference request and measure response time
686async 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    // Build request body with benchmark params
695    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    // Robust timings parsing
728    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
760/// Save benchmark results to disk in Markdown format
761pub async fn save_results(
762    results: &[BenchTuneResult],
763    output_dir: &PathBuf,
764    config: &BenchTuneConfig,
765) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
766    // Create output directory if it doesn't exist
767    std::fs::create_dir_all(output_dir)?;
768
769    // Generate timestamp for the filename
770    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    // Save full results as JSON with outputs
866    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    // Also save full results as YAML with outputs
872    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    // Generate HTML report
878    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    // Extract model metadata from first result's base_settings
893    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    // Resolve benchmark params against base settings (fill in None with base values)
914    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    // Statistics helpers
949    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    // Per-parameter impact analysis
1046    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            // Group by parameter value and compute mean gen_tps per group
1086            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            } // Parameter doesn't vary
1107
1108            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    // Sort by impact (spread) descending
1115    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    // Consistency indicator (coefficient of variation from per-iteration metrics)
1119    let consistency_data: Vec<f64> = results
1120        .iter()
1121        .map(|r| {
1122            if r.per_iteration_metrics.len() <= 1 {
1123                return 1.0; // No consistency data = neutral
1124            }
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; // Coefficient of variation
1136            // Map CV to 0-1 score (lower CV = more consistent = higher score)
1137            // CV of 0% = 1.0 (perfect), CV of 20%+ = 0.0 (poor)
1138            (1.0 - (cv * 5.0)).clamp(0.0, 1.0)
1139        })
1140        .collect();
1141
1142    // Top N for charts
1143    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    // Scatter data with labels
1165    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    // Build metrics JSON with consistency data
1214    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    // Scatter data with labels for tooltips
1254    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    // Model metadata JSON
1291    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    // Impact analysis JSON
1308    let _impact_json = serde_json::to_string(&impact_sorted).unwrap();
1309
1310    // Column definitions for visibility toggle
1311    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    // CSV data
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";
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(&param_headers).unwrap();
1378    let param_vals_json = serde_json::to_string(&param_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    // Build model metadata HTML
1383    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 &amp; 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    // Build winner section HTML
1412    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">&#127942;</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} &middot; Top-P: {:.2} &middot; Top-K: {} &middot; RepPen: {:.2} &middot; FA: {} &middot; Threads: {} &middot; Batch: {} &middot; Exp: {} &middot; Spec: {} &middot; 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    // Build impact analysis HTML
1440    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    // Empty state
1481    let empty_html = if total_tests == 0 {
1482        r#"<div class="empty-state">
1483<div class="empty-icon">&#128202;</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    // Replace placeholders
1494    html.replace("__TIMESTAMP__", &timestamp)
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__", &param_headers_json)
1530        .replace("__PARAM_VALS_JSON__", &param_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
1543/// Escape HTML special characters
1544fn escape_html(s: &str) -> String {
1545    s.replace('&', "&amp;")
1546        .replace('<', "&lt;")
1547        .replace('>', "&gt;")
1548        .replace('"', "&quot;")
1549}
1550
1551/// Base64 encode a string (no external dependency - simple encoding)
1552fn 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
1577/// Result from a single inference request
1578struct 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, // milliseconds
1585    content: String,
1586}