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