Skip to main content

ferrum_cli/commands/
bench.rs

1//! Bench command — single-process CLI benchmark (no HTTP).
2//!
3//! Same forward path as `ferrum run` (batch=1 by default, no HTTP). Emits
4//! the canonical `BenchReport` schema from `ferrum-bench-core`. This is
5//! the "Level 1" command in PLAYBOOK § 1 — single-user feel of the engine.
6//!
7//! Modes:
8//!   ferrum bench qwen3:4b                              # default: sequential, 5 rounds
9//!   ferrum bench qwen3:4b --concurrency 4              # concurrent (tests batch decode)
10//!   ferrum bench qwen3:4b --max-tokens 1024            # long decode (tests flash decode)
11//!   ferrum bench qwen3:4b --long-context               # 2k prompt + 256 decode
12//!   ferrum bench qwen3:4b --concurrency 8 --max-tokens 64  # throughput stress
13//!
14//! Phase 0 additions:
15//!   --n-repeats N    independent runs (≥3 unlocks stddev + CI95)
16//!   --goodput ...    SLO triple for goodput computation
17//!   --output json    emit canonical BenchReport JSON
18//!   --out PATH       write JSON to file (else stdout)
19
20use crate::config::CliConfig;
21use chrono::Utc;
22use clap::Args;
23use colored::*;
24use ferrum_bench_core::{
25    compute_metrics, BenchReport, Env, ItlEvidenceSource, OutputTokenCountSource,
26    QualityIssueCounts, RequestItlEvidence, RequestRecord, RunRecord, Scenario, Slo,
27};
28use ferrum_types::{InferenceRequest, Priority, RequestId, Result, SamplingParams};
29use futures::StreamExt;
30use std::collections::HashMap;
31use std::path::PathBuf;
32use std::time::Instant;
33use uuid::Uuid;
34
35#[derive(Args)]
36pub struct BenchCommand {
37    /// Model name (e.g., qwen3:0.6b, qwen3:4b)
38    #[arg(default_value = "qwen3:0.6b")]
39    pub model: String,
40
41    /// Sequential inference passes per run.
42    #[arg(long, default_value = "5")]
43    pub rounds: usize,
44
45    /// Max tokens per request
46    #[arg(long, default_value = "128")]
47    pub max_tokens: u32,
48
49    /// Backend: auto, cpu, cuda, metal
50    #[arg(long, default_value = "auto")]
51    pub backend: String,
52
53    /// Numerical execution profile: auto or an exact family profile ID.
54    #[arg(long, value_name = "PROFILE")]
55    pub numerical_profile: Option<ferrum_types::NumericalExecutionPolicy>,
56
57    /// Prompt to use
58    #[arg(long, default_value = "Explain the theory of relativity in detail.")]
59    pub prompt: String,
60
61    /// Number of concurrent requests (>1 tests batch decode).
62    #[arg(long, default_value = "1")]
63    pub concurrency: usize,
64
65    /// Long-context mode: use a ~2k token prompt (tests flash decode / paged KV).
66    #[arg(long)]
67    pub long_context: bool,
68
69    /// KV cache element dtype. Accepts `fp16`, `bf16`, `int8`, `fp8`.
70    #[arg(long, value_name = "DTYPE")]
71    pub kv_dtype: Option<String>,
72
73    // ─── Phase 0 additions (canonical schema) ─────────────────────
74    /// Independent repeats of the whole bench. ≥ 3 unlocks CI95
75    /// (PLAYBOOK § 0.4). Default 1 — emits mean only.
76    #[arg(long, default_value_t = 1)]
77    pub n_repeats: u32,
78
79    /// SLO triple for goodput. Format: `ttft:500 tpot:50 e2el:30000`.
80    #[arg(long, value_parser = super::bench_serve::parse_slo)]
81    pub goodput: Option<Slo>,
82
83    /// Output format: `json` (canonical BenchReport) or `human` (summary
84    /// table, default — matches pre-Phase-0 behaviour).
85    #[arg(long, default_value = "human")]
86    pub output: String,
87
88    /// Output file path (json mode).
89    #[arg(long)]
90    pub out: Option<PathBuf>,
91
92    /// Override `env.hw_id` (defaults to auto-detected).
93    #[arg(long)]
94    pub hw_id: Option<String>,
95
96    /// Override `env.commit_sha` (defaults to `git rev-parse --short HEAD`).
97    #[arg(long)]
98    pub commit_sha: Option<String>,
99}
100
101fn validate_command(cmd: &BenchCommand) -> Result<()> {
102    if cmd.n_repeats == 0 {
103        return Err(ferrum_types::FerrumError::model("--n-repeats must be > 0"));
104    }
105    if cmd.rounds == 0 {
106        return Err(ferrum_types::FerrumError::model("--rounds must be > 0"));
107    }
108    if cmd.concurrency == 0 {
109        return Err(ferrum_types::FerrumError::model(
110            "--concurrency must be > 0",
111        ));
112    }
113    measured_request_count(cmd)?;
114    if cmd.goodput.is_some_and(|slo| !slo.is_valid()) {
115        return Err(ferrum_types::FerrumError::model(
116            "--goodput values must be positive finite numbers",
117        ));
118    }
119    Ok(())
120}
121
122fn measured_request_count(cmd: &BenchCommand) -> Result<u32> {
123    let count = cmd
124        .rounds
125        .checked_mul(cmd.concurrency)
126        .ok_or_else(|| ferrum_types::FerrumError::model("rounds * concurrency overflow"))?;
127    u32::try_from(count).map_err(|_| {
128        ferrum_types::FerrumError::model("rounds * concurrency exceeds report capacity")
129    })
130}
131
132pub async fn execute(cmd: BenchCommand, config: CliConfig) -> Result<()> {
133    validate_command(&cmd)?;
134    let cache_dir = crate::source_resolver::hf_cache_dir(&config);
135    let resolved = crate::source_resolver::resolve_model_source(
136        &cmd.model,
137        &cache_dir,
138        crate::source_resolver::DownloadPolicy::AutoDownload,
139        None,
140    )
141    .await?;
142    let product_input = resolved.into_product_engine_input();
143    let model_id = product_input.public_model_id.clone();
144    let source = product_input.source;
145    let mut engine_config = product_input.engine_config;
146    engine_config.numerical_execution =
147        config.resolve_numerical_execution(cmd.numerical_profile.as_ref());
148    let model_sources = product_input.model_sources;
149    let defined_model = crate::source_resolver::define_registered_product_model(
150        model_sources.as_ref(),
151        &engine_config.numerical_execution,
152        engine_config.kv_cache.dtype,
153    )?;
154    eprintln!("{}", format!("Ferrum Benchmark - {}", model_id).bold());
155    eprintln!("{}", "=".repeat(60).dimmed());
156
157    let engine_model_path = source.local_path.to_string_lossy().to_string();
158
159    let device = super::run::select_device(&cmd.backend)?;
160    let backend_str = format!("{:?}", device).to_lowercase();
161    eprintln!("{} {:?}", "Device:".dimmed(), device);
162    let runtime_config = ferrum_types::RuntimeConfigSnapshot::capture_current();
163
164    #[cfg(feature = "cuda")]
165    {
166        let graph_mode =
167            crate::runtime_env::runtime_snapshot_value(&runtime_config, "FERRUM_CUDA_GRAPH")
168                .is_some();
169        if !graph_mode {
170            if let Ok(name) = ferrum_kernels::cuda_device_name(0) {
171                eprintln!("GPU 0: {name}");
172            }
173        }
174        let tp = crate::runtime_env::runtime_snapshot_value(&runtime_config, "FERRUM_TP")
175            .and_then(|v| v.parse::<usize>().ok())
176            .unwrap_or_else(|| ferrum_kernels::cuda_device_count().unwrap_or(1));
177        if tp > 1 {
178            eprintln!("Tensor Parallel: TP={tp}");
179        }
180    }
181
182    engine_config.sampling.default_params = bench_sampling_params(cmd.max_tokens);
183    engine_config.backend.device = device;
184    engine_config.backend.backend_options.insert(
185        "model_path".to_string(),
186        serde_json::Value::String(engine_model_path),
187    );
188    engine_config.scheduler.policy = ferrum_types::SchedulingPolicy::ContinuousBatch;
189    engine_config
190        .apply_runtime_config_snapshot(&runtime_config)
191        .map_err(ferrum_types::FerrumError::config)?;
192    let effective_kv_dtype = cmd
193        .kv_dtype
194        .as_deref()
195        .or_else(|| crate::runtime_env::runtime_snapshot_value(&runtime_config, "FERRUM_KV_DTYPE"));
196    super::run::apply_kv_dtype_override(&mut engine_config, effective_kv_dtype)?;
197    let engine = match (defined_model, model_sources) {
198        (Some(prepared), _) => {
199            ferrum_engine::create_defined_product_engine(engine_config, prepared).await?
200        }
201        (None, Some(sources)) => {
202            ferrum_engine::create_product_engine(engine_config, sources).await?
203        }
204        (None, None) => ferrum_engine::create_default_engine(engine_config).await?,
205    };
206
207    let prompt = if cmd.long_context {
208        generate_long_prompt()
209    } else {
210        cmd.prompt.clone()
211    };
212
213    let mode_str = if cmd.concurrency > 1 {
214        format!("concurrent({})", cmd.concurrency)
215    } else if cmd.long_context {
216        "long-context".to_string()
217    } else {
218        "sequential".to_string()
219    };
220
221    eprintln!(
222        "{}",
223        format!(
224            "Config: {} rounds × {} repeats, {} max_tokens, mode={}, prompt_len=~{}chars",
225            cmd.rounds,
226            cmd.n_repeats,
227            cmd.max_tokens,
228            mode_str,
229            prompt.len()
230        )
231        .dimmed()
232    );
233    if cmd.n_repeats < 3 {
234        eprintln!(
235            "{}",
236            "  [warn] n_repeats < 3 — emitting mean only, no stddev/CI95 (PLAYBOOK § 0.4)".yellow()
237        );
238    }
239    eprintln!("{}", "=".repeat(60).dimmed());
240
241    // Warmup (discarded — once per process, not per repeat).
242    eprintln!("{}", "Warmup...".dimmed());
243    let warmup = run_single(&*engine, &model_id, "Hello", 16).await?;
244    if !warmup.success {
245        return Err(ferrum_types::FerrumError::model(
246            "benchmark warmup request failed",
247        ));
248    }
249    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
250
251    // ─── n_repeats × rounds run loop ──────────────────────────────
252    let mut runs: Vec<RunRecord> = Vec::with_capacity(cmd.n_repeats as usize);
253    for repeat_idx in 0..cmd.n_repeats {
254        eprintln!(
255            "{}",
256            format!("Repeat {}/{}", repeat_idx + 1, cmd.n_repeats).bold()
257        );
258        let run = if cmd.concurrency > 1 {
259            run_concurrent_round(&*engine, &model_id, &prompt, &cmd).await?
260        } else {
261            run_sequential_round(&*engine, &model_id, &prompt, &cmd).await?
262        };
263        eprintln!(
264            "  {} requests · {:.1}s · {:.1} tok/s",
265            run.records.len(),
266            run.duration_s,
267            run.records
268                .iter()
269                .map(|r| r.output_tokens as f64)
270                .sum::<f64>()
271                / run.duration_s
272        );
273        runs.push(run);
274    }
275
276    // ─── Aggregate + emit ─────────────────────────────────────────
277    let scenario = if cmd.concurrency > 1 {
278        // Closed-loop with K workers (concurrency).
279        Scenario::ClosedLoop
280    } else {
281        Scenario::Cli
282    };
283    let concurrency = if cmd.concurrency > 1 {
284        Some(cmd.concurrency as u32)
285    } else {
286        None
287    };
288
289    let env = build_env(&cmd);
290    let slo = cmd.goodput.unwrap_or_else(Slo::unbounded);
291    let prompt_len = u32::try_from(prompt.len()).map_err(|_| {
292        ferrum_types::FerrumError::model("benchmark prompt length exceeds report capacity")
293    })?;
294    let report = compute_metrics(
295        model_id.clone(),
296        backend_str,
297        scenario,
298        concurrency,
299        None,
300        prompt_len, // n_prompt char approx — true token count requires tokenizer
301        cmd.max_tokens,
302        0, // CLI bench: process-level warmup only (not per-repeat)
303        slo,
304        runs,
305        env,
306    );
307
308    emit_then_enforce_bench_report(&cmd, &report, &mode_str)
309}
310
311// ── Run loops (one round = N sequential or concurrent passes) ───────
312
313async fn run_sequential_round(
314    engine: &(dyn ferrum_interfaces::engine::LlmInferenceEngine + Send + Sync),
315    model_id: &str,
316    prompt: &str,
317    cmd: &BenchCommand,
318) -> Result<RunRecord> {
319    let expected_requests = measured_request_count(cmd)?;
320    let mut records = Vec::with_capacity(cmd.rounds);
321    let start = Instant::now();
322    for _ in 0..cmd.rounds {
323        match run_single(engine, model_id, prompt, cmd.max_tokens).await {
324            Ok(record) => records.push(record),
325            Err(error) => {
326                eprintln!("  request start error: {error}");
327                let mut quality = QualityIssueCounts::default();
328                quality.malformed_stream = 1;
329                records.push(failed_bench_record(quality));
330            }
331        }
332        // Let engine finish cleanup between rounds.
333        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
334    }
335    let duration_s = start.elapsed().as_secs_f64();
336    Ok(RunRecord {
337        records,
338        expected_requests,
339        duration_s,
340        warmup: Default::default(),
341    })
342}
343
344async fn run_concurrent_round(
345    engine: &(dyn ferrum_interfaces::engine::LlmInferenceEngine + Send + Sync),
346    model_id: &str,
347    prompt: &str,
348    cmd: &BenchCommand,
349) -> Result<RunRecord> {
350    let expected_requests = measured_request_count(cmd)?;
351    let mut records = Vec::with_capacity(expected_requests as usize);
352    let start = Instant::now();
353    for _ in 0..cmd.rounds {
354        let mut handles = Vec::with_capacity(cmd.concurrency);
355        for _ in 0..cmd.concurrency {
356            let request = make_request(model_id, prompt, cmd.max_tokens);
357            match engine.infer_stream(request).await {
358                Ok(stream) => handles.push(tokio::spawn(collect_stream(stream))),
359                Err(error) => {
360                    eprintln!("  request start error: {error}");
361                    let mut quality = QualityIssueCounts::default();
362                    quality.malformed_stream = 1;
363                    records.push(failed_bench_record(quality));
364                }
365            }
366        }
367        for handle in handles {
368            match handle.await {
369                Ok(Ok(r)) => records.push(r),
370                Ok(Err(e)) => {
371                    eprintln!("  request error: {e}");
372                    let mut quality = QualityIssueCounts::default();
373                    quality.malformed_stream = 1;
374                    records.push(failed_bench_record(quality));
375                }
376                Err(e) => {
377                    eprintln!("  join error: {e}");
378                    let mut quality = QualityIssueCounts::default();
379                    quality.panic = 1;
380                    records.push(failed_bench_record(quality));
381                }
382            }
383        }
384        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
385    }
386    let duration_s = start.elapsed().as_secs_f64();
387    Ok(RunRecord {
388        records,
389        expected_requests,
390        duration_s,
391        warmup: Default::default(),
392    })
393}
394
395fn failed_bench_record(quality_issues: QualityIssueCounts) -> RequestRecord {
396    RequestRecord {
397        benchmark_correlation: None,
398        server_request_id: None,
399        success: false,
400        ttft_ms: 0.0,
401        e2e_ms: 0.0,
402        input_tokens: 0,
403        server_input_tokens: None,
404        output_tokens: 0,
405        output_token_count_source: OutputTokenCountSource::None,
406        itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::EngineTokenEvents),
407        quality_issues,
408        itl_ms: vec![],
409    }
410}
411
412// ── Single-stream collection ────────────────────────────────────────
413
414fn make_request(model_id: &str, prompt: &str, max_tokens: u32) -> InferenceRequest {
415    InferenceRequest {
416        id: RequestId(Uuid::new_v4()),
417        model_id: ferrum_types::ModelId(model_id.to_string()),
418        prompt: prompt.to_string(),
419        sampling_params: bench_sampling_params(max_tokens),
420        stream: true,
421        priority: Priority::Normal,
422        client_id: None,
423        session_id: None,
424        created_at: Utc::now(),
425        api_request: None,
426        evidence_request: Default::default(),
427        metadata: HashMap::new(),
428    }
429}
430
431fn bench_sampling_params(max_tokens: u32) -> SamplingParams {
432    SamplingParams {
433        max_tokens: max_tokens as usize,
434        temperature: 0.0, // greedy — matches PLAYBOOK § 0.5 L3 determinism contract
435        top_p: 1.0,
436        repetition_penalty: 1.0,
437        stop_sequences: vec![
438            "<|im_end|>".to_string(),
439            "</s>".to_string(),
440            "<|endoftext|>".to_string(),
441        ],
442        ..Default::default()
443    }
444}
445
446async fn run_single(
447    engine: &(dyn ferrum_interfaces::engine::LlmInferenceEngine + Send + Sync),
448    model_id: &str,
449    prompt: &str,
450    max_tokens: u32,
451) -> Result<RequestRecord> {
452    let request = make_request(model_id, prompt, max_tokens);
453    let stream = engine.infer_stream(request).await?;
454    Ok(collect_stream(stream).await?)
455}
456
457async fn collect_stream(
458    mut stream: std::pin::Pin<
459        Box<
460            dyn futures::Stream<
461                    Item = std::result::Result<
462                        ferrum_types::StreamChunk,
463                        ferrum_types::FerrumError,
464                    >,
465                > + Send,
466        >,
467    >,
468) -> Result<RequestRecord> {
469    let start = Instant::now();
470    let mut token_count: u32 = 0;
471    let mut first_token_time: Option<Instant> = None;
472    let mut last_token_time: Option<Instant> = None;
473    let mut itl_ms: Vec<f64> = Vec::new();
474    let mut quality_issues = QualityIssueCounts::default();
475
476    let mut got_finish = false;
477    while let Some(result) = stream.next().await {
478        match result {
479            Ok(chunk) => {
480                if chunk.token.is_some() {
481                    let now = Instant::now();
482                    if first_token_time.is_none() {
483                        first_token_time = Some(now);
484                    } else if let Some(prev) = last_token_time {
485                        itl_ms.push((now - prev).as_secs_f64() * 1000.0);
486                    }
487                    last_token_time = Some(now);
488                    token_count = token_count
489                        .checked_add(1)
490                        .expect("benchmark output token count overflow");
491                }
492                if chunk.finish_reason.is_some() {
493                    got_finish = true;
494                    break;
495                }
496            }
497            Err(_) => {
498                quality_issues.malformed_stream = 1;
499                break;
500            }
501        }
502    }
503    if !got_finish {
504        quality_issues.missing_done = 1;
505        if token_count > 0 {
506            eprintln!(
507                "  [warn] stream ended without finish_reason ({} tokens)",
508                token_count
509            );
510        }
511    }
512    if token_count == 0 {
513        quality_issues.zero_output_tokens = 1;
514    }
515
516    let e2e_ms = start.elapsed().as_secs_f64() * 1000.0;
517    let ttft_ms = first_token_time
518        .map(|t| t.duration_since(start).as_secs_f64() * 1000.0)
519        .unwrap_or(e2e_ms);
520
521    let success = token_count > 0 && got_finish && quality_issues.request_error_count() == 0;
522    let observed_intervals =
523        u32::try_from(itl_ms.len()).expect("benchmark ITL interval count overflow");
524    Ok(RequestRecord {
525        benchmark_correlation: None,
526        server_request_id: None,
527        success,
528        ttft_ms,
529        e2e_ms,
530        input_tokens: 0, // CLI bench doesn't tokenize; left as 0
531        server_input_tokens: None,
532        output_tokens: token_count,
533        output_token_count_source: if token_count > 0 {
534            OutputTokenCountSource::StreamChunks
535        } else {
536            OutputTokenCountSource::None
537        },
538        itl_evidence: RequestItlEvidence::engine(success, token_count, observed_intervals),
539        quality_issues,
540        itl_ms,
541    })
542}
543
544// ── Env construction ────────────────────────────────────────────────
545
546fn build_env(cmd: &BenchCommand) -> Env {
547    let commit_sha = cmd
548        .commit_sha
549        .clone()
550        .or_else(|| {
551            std::process::Command::new("git")
552                .args(["rev-parse", "--short", "HEAD"])
553                .output()
554                .ok()
555                .and_then(|o| String::from_utf8(o.stdout).ok())
556                .map(|s| s.trim().to_string())
557        })
558        .unwrap_or_else(|| "unknown".to_string());
559
560    #[allow(unused_mut)]
561    let mut features: Vec<String> = Vec::new();
562    #[cfg(feature = "metal")]
563    features.push("metal".to_string());
564    #[cfg(feature = "cuda")]
565    features.push("cuda".to_string());
566
567    let mut env = Env::capture_minimal(commit_sha, features);
568    if let Some(hw) = cmd.hw_id.clone() {
569        env.hw_id = hw;
570    }
571    env
572}
573
574// ── Output formatters ───────────────────────────────────────────────
575
576fn print_human_summary(report: &BenchReport, cmd: &BenchCommand, mode_str: &str) {
577    eprintln!();
578    eprintln!("{}", "=".repeat(60));
579    eprintln!("{}", format!("BENCHMARK RESULTS ({})", mode_str).bold());
580    eprintln!("{}", "=".repeat(60));
581    eprintln!("Model:             {}", report.model);
582    eprintln!("Backend:           {}", report.backend);
583    eprintln!("Rounds:            {}", cmd.rounds);
584    eprintln!("Repeats:           {}", report.n_repeats);
585    eprintln!("Max tokens/req:    {}", cmd.max_tokens);
586    if let Some(c) = report.concurrency {
587        eprintln!("Concurrency:       {}", c);
588    }
589    eprintln!("{}", "-".repeat(60));
590    let fmt = |s: &ferrum_bench_core::ScalarStats| -> String {
591        if report.n_repeats >= 3 {
592            format!("{:.1} ± {:.1}", s.mean, s.ci95_hw)
593        } else {
594            format!("{:.1}", s.mean)
595        }
596    };
597    eprintln!(
598        "TTFT_ms      p50={}  p95={}  p99={}",
599        fmt(&report.ttft_ms.p50),
600        fmt(&report.ttft_ms.p95),
601        fmt(&report.ttft_ms.p99)
602    );
603    eprintln!(
604        "TPOT_ms      p50={}  p95={}  p99={}",
605        fmt(&report.tpot_ms.p50),
606        fmt(&report.tpot_ms.p95),
607        fmt(&report.tpot_ms.p99)
608    );
609    if report.has_complete_itl_evidence() {
610        eprintln!(
611            "ITL_ms       p50={}  p95={}  p99={}",
612            fmt(&report.itl_ms.p50),
613            fmt(&report.itl_ms.p95),
614            fmt(&report.itl_ms.p99)
615        );
616    } else {
617        eprintln!("ITL_ms       unavailable");
618    }
619    eprintln!("Output thr   {} tok/s", fmt(&report.output_throughput_tps));
620    if cmd.goodput.is_some() {
621        eprintln!("Goodput      {} req/s", fmt(&report.goodput_rps));
622    }
623    eprintln!("env_hash:    {}", report.env_hash);
624    eprintln!("{}", "=".repeat(60));
625}
626
627fn emit_json(cmd: &BenchCommand, report: &BenchReport) -> Result<()> {
628    let pretty = serde_json::to_string_pretty(report).expect("serialize");
629    if let Some(out) = cmd.out.as_ref() {
630        std::fs::write(out, &pretty).map_err(|e| {
631            ferrum_types::FerrumError::model(format!("write {}: {e}", out.display()))
632        })?;
633        eprintln!("\n→ wrote {}", out.display());
634    } else {
635        println!("{}", pretty);
636    }
637    Ok(())
638}
639
640fn emit_then_enforce_bench_report(
641    cmd: &BenchCommand,
642    report: &BenchReport,
643    mode_str: &str,
644) -> Result<()> {
645    match cmd.output.as_str() {
646        "human" => print_human_summary(report, cmd, mode_str),
647        "json" => emit_json(cmd, report)?,
648        other => {
649            return Err(ferrum_types::FerrumError::model(format!(
650                "unknown --output '{other}': allowed values are human, json"
651            )))
652        }
653    }
654
655    // PLAYBOOK § 1.5: Rust's `static` items don't run Drop on program
656    // exit, so the global TraceWriter's flush-on-drop never fires. Call
657    // it explicitly here. No-op when FERRUM_TRACE_OUT is unset.
658    ferrum_bench_core::trace::flush_global_trace();
659    enforce_bench_error_policy(report)
660}
661
662fn enforce_bench_error_policy(report: &BenchReport) -> Result<()> {
663    let errored = report
664        .errored_per_run
665        .iter()
666        .try_fold(0_u64, |total, value| total.checked_add(*value as u64))
667        .ok_or_else(|| ferrum_types::FerrumError::model("benchmark error count overflow"))?;
668    if errored > 0 {
669        return Err(ferrum_types::FerrumError::model(format!(
670            "benchmark measured requests failed: {errored}"
671        )));
672    }
673    Ok(())
674}
675
676/// Generate a ~2k token prompt for long-context benchmarking.
677fn generate_long_prompt() -> String {
678    let base = "The history of artificial intelligence is a fascinating journey through decades of research, breakthroughs, and setbacks. From the early days of symbolic AI in the 1950s, through the AI winters, to the modern era of deep learning and large language models, the field has undergone remarkable transformations. ";
679    let mut prompt = String::with_capacity(8192);
680    prompt.push_str("Please provide a comprehensive analysis of the following text, identifying key themes, patterns, and insights:\n\n");
681    for _ in 0..25 {
682        prompt.push_str(base);
683    }
684    prompt.push_str("\n\nNow analyze the above text in detail:");
685    prompt
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    fn command() -> BenchCommand {
693        BenchCommand {
694            model: "test".to_string(),
695            rounds: 3,
696            max_tokens: 4,
697            backend: "cpu".to_string(),
698            numerical_profile: None,
699            prompt: "hello".to_string(),
700            concurrency: 2,
701            long_context: false,
702            kv_dtype: None,
703            n_repeats: 1,
704            goodput: None,
705            output: "json".to_string(),
706            out: None,
707            hw_id: None,
708            commit_sha: None,
709        }
710    }
711
712    #[test]
713    fn validates_expected_request_count_and_numeric_inputs() {
714        let mut cmd = command();
715        assert_eq!(measured_request_count(&cmd).unwrap(), 6);
716        assert_eq!(
717            measured_request_count(&cmd).unwrap() as usize,
718            cmd.rounds * cmd.concurrency
719        );
720        assert!(validate_command(&cmd).is_ok());
721        cmd.concurrency = 1;
722        assert_eq!(measured_request_count(&cmd).unwrap() as usize, cmd.rounds);
723        assert!(validate_command(&cmd).is_ok());
724        cmd.n_repeats = 0;
725        assert!(validate_command(&cmd).is_err());
726        cmd.n_repeats = 1;
727        cmd.goodput = Some(Slo {
728            ttft_p99_ms: f64::INFINITY,
729            ..Slo::default()
730        });
731        assert!(validate_command(&cmd).is_err());
732    }
733
734    #[test]
735    fn failed_bench_record_is_explicit_error_evidence() {
736        let mut quality = QualityIssueCounts::default();
737        quality.panic = 1;
738        let record = failed_bench_record(quality);
739        assert!(!record.success);
740        assert_eq!(record.quality_issues.panic, 1);
741        assert_eq!(
742            record.output_token_count_source,
743            OutputTokenCountSource::None
744        );
745        assert_eq!(
746            record.itl_evidence.source,
747            ItlEvidenceSource::EngineTokenEvents
748        );
749    }
750
751    #[test]
752    fn failed_bench_report_is_written_before_nonzero_result() {
753        let out = std::env::temp_dir().join(format!(
754            "ferrum-bench-failed-report-{}-{}.json",
755            std::process::id(),
756            std::time::SystemTime::now()
757                .duration_since(std::time::UNIX_EPOCH)
758                .unwrap()
759                .as_nanos()
760        ));
761        let _ = std::fs::remove_file(&out);
762        let mut quality = QualityIssueCounts::default();
763        quality.malformed_stream = 1;
764        let report = compute_metrics(
765            "test".to_string(),
766            "cpu".to_string(),
767            Scenario::Cli,
768            None,
769            None,
770            1,
771            1,
772            0,
773            Slo::default(),
774            vec![RunRecord {
775                records: vec![failed_bench_record(quality)],
776                expected_requests: 1,
777                duration_s: 1.0,
778                warmup: Default::default(),
779            }],
780            Env::default(),
781        );
782        let mut cmd = command();
783        cmd.out = Some(out.clone());
784        let error = emit_then_enforce_bench_report(&cmd, &report, "test")
785            .expect_err("failed measured request must return nonzero");
786        assert!(error.to_string().contains("measured requests failed"));
787        let json: serde_json::Value =
788            serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
789        assert_eq!(json["errored_per_run"], serde_json::json!([1]));
790        assert_eq!(json["repeat_metrics"][0]["itl_ineligible_requests"], 1);
791        let _ = std::fs::remove_file(out);
792    }
793}