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