Skip to main content

qualia_core_db/inference/inference_bench/
runner.rs

1//! The public benchmark entry points — drive one case (cold then warm) through
2//! the real inference path, average warm repeats, and assemble a [`BenchResult`];
3//! plus the suite runners and their blocking (own-runtime) wrappers. Includes the
4//! internal one-shot timing (`RunTiming` / `timed_infer`). Pure code motion.
5
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8
9use crate::llm_agent::{AgentBackend, LocalLlmAgent};
10
11use super::metrics::{ms, ns_to_ms, tok_per_s};
12use super::*;
13
14// ── Internal one-shot timing ──────────────────────────────────────────────────
15
16struct RunTiming {
17    ttft: Duration,
18    total: Duration,
19    output_tokens: u64,
20}
21
22/// Drive one real inference, timestamping the first/last streamed token.
23fn timed_infer(agent: &LocalLlmAgent, prompt: &str) -> RunTiming {
24    let stamps: Arc<Mutex<Vec<Instant>>> = Arc::new(Mutex::new(Vec::with_capacity(64)));
25    let stamps_cb = Arc::clone(&stamps);
26    let start = Instant::now();
27    let cb = move |_delta: String| {
28        if let Ok(mut v) = stamps_cb.lock() {
29            v.push(Instant::now());
30        }
31    };
32    let (_text, _prov, tokens, _quin) = agent.infer_local_model_streaming(prompt, "", Some(cb));
33    let total = start.elapsed();
34    let v = stamps.lock().map(|g| g.clone()).unwrap_or_default();
35    let ttft = v.first().map(|f| f.duration_since(start)).unwrap_or(total);
36    RunTiming {
37        ttft,
38        total,
39        output_tokens: tokens as u64,
40    }
41}
42
43// ── Public entry ──────────────────────────────────────────────────────────────
44
45/// Run one benchmark case end-to-end (cold then warm) against the real path.
46///
47/// Must be called from within a multi-thread Tokio runtime context (the residency
48/// mount uses `block_in_place`). Returns `Err` if the model file is absent.
49pub fn run_bench(cfg: &BenchConfig) -> Result<BenchResult, String> {
50    if !std::path::Path::new(&cfg.model_path).exists() {
51        return Err(format!("model not found: {}", cfg.model_path));
52    }
53
54    let agent = LocalLlmAgent::with_local_backend(
55        "did:qualia:bench",
56        AgentBackend::Local {
57            model_path: cfg.model_path.clone(),
58            context_window: 4096,
59            quantization: cfg.quantization.clone(),
60            vision_projector_path: None,
61            modality: "text".into(),
62            architecture: None,
63        },
64    );
65
66    // Bound decode for a stable, comparable measurement.
67    set_decode_budget_override(cfg.decode_tokens);
68
69    // ── COLD: ensure the model is NOT resident, then measure a fresh run. ──
70    crate::resident_model::clear_resident_model();
71    reset_phase_metrics();
72    let cold = timed_infer(&agent, &cfg.prompt);
73
74    // ── Make resident so warm runs adopt the mmap (skip disk load). ──
75    let model_id = crate::q_hash(&cfg.model_path);
76    let mut meta = ModelMeta::default();
77    if let Ok(report) = crate::resident_model::mount_resident_gguf(model_id, &cfg.model_path, false)
78    {
79        meta = ModelMeta {
80            n_layer: report.n_layer,
81            n_head: report.n_head,
82            n_kv_head: report.n_kv_head,
83            mapped_bytes: report.mapped_bytes,
84            kv_cache_bytes: report.kv_cache_bytes,
85            directml_enabled: report.directml_enabled,
86        };
87    }
88
89    // ── WARM: average over repeats. ──
90    let repeats = cfg.warm_repeats.max(1);
91    let mut warm_ttft = Duration::ZERO;
92    let mut warm_total = Duration::ZERO;
93    let mut acc_load = 0.0f64;
94    let mut acc_prefill_ns = 0u64;
95    let mut acc_prefill_tok = 0u64;
96    let mut acc_decode_ns = 0u64;
97    let mut acc_decode_tok = 0u64;
98    let mut last_warm = RunTiming {
99        ttft: Duration::ZERO,
100        total: Duration::ZERO,
101        output_tokens: 0,
102    };
103    for _ in 0..repeats {
104        reset_phase_metrics();
105        let w = timed_infer(&agent, &cfg.prompt);
106        let snap = phase_snapshot();
107        warm_ttft += w.ttft;
108        warm_total += w.total;
109        acc_load += ns_to_ms(snap.load_ns);
110        acc_prefill_ns += snap.prefill_ns;
111        acc_prefill_tok += snap.prefill_tokens;
112        acc_decode_ns += snap.decode_ns;
113        acc_decode_tok += snap.decode_tokens;
114        last_warm = w;
115    }
116    let n = repeats as u32;
117
118    crate::resident_model::clear_resident_model();
119    set_decode_budget_override(0); // restore production default
120
121    let prompt_tokens = if repeats > 0 {
122        acc_prefill_tok / repeats as u64 + 1 // prefill covers prompt_len-1
123    } else {
124        0
125    };
126
127    let shared_gpu = crate::gpu_context::shared_gpu();
128
129    Ok(BenchResult {
130        label: cfg.label.clone(),
131        model_path: cfg.model_path.clone(),
132        quantization: cfg.quantization.clone(),
133        model: meta,
134        gpu: BenchGpuMeta::from_shared_context(shared_gpu),
135        prompt_tokens,
136        output_tokens: last_warm.output_tokens,
137        cold_ttft_ms: ms(cold.ttft),
138        cold_total_ms: ms(cold.total),
139        warm_ttft_ms: ms(warm_ttft) / n as f64,
140        warm_total_ms: ms(warm_total) / n as f64,
141        load_ms: acc_load / n as f64,
142        prefill_ms: ns_to_ms(acc_prefill_ns) / n as f64,
143        prefill_tok_s: tok_per_s(acc_prefill_tok, acc_prefill_ns),
144        decode_ms: ns_to_ms(acc_decode_ns) / n as f64,
145        decode_tok_s: tok_per_s(acc_decode_tok, acc_decode_ns),
146        // W2/D17: report the real device capability (TIMESTAMP_QUERY negotiation), not a hardcoded
147        // false. Per-kernel µs come from the dedicated `w2_gpu_phase_profile` test (a profiled run
148        // perturbs the headline tok/s, so the baseline run is left unprofiled).
149        gpu_timestamp_supported: shared_gpu.timestamps_supported,
150        note: String::new(),
151    })
152}
153
154/// Run a suite of cases, skipping any whose model file is absent.
155pub fn run_suite(cfgs: &[BenchConfig]) -> Vec<BenchResult> {
156    let mut out = Vec::new();
157    for c in cfgs {
158        match run_bench(c) {
159            Ok(r) => out.push(r),
160            Err(e) => log::warn!("llm_bench|skip|{}|{}", c.label, e),
161        }
162    }
163    out
164}
165
166/// Run a suite inside a fresh multi-thread Tokio runtime.
167///
168/// `mount_resident_gguf` uses `block_in_place`, which requires a multi-thread
169/// runtime context — this wrapper provides one so callers (tests, CLI) don't have
170/// to. Safe to call from a plain (non-async) thread.
171pub fn run_suite_blocking(cfgs: &[BenchConfig]) -> Vec<BenchResult> {
172    let rt = tokio::runtime::Builder::new_multi_thread()
173        .worker_threads(2)
174        .enable_all()
175        .build()
176        .expect("tokio multi-thread runtime for llm_bench");
177    rt.block_on(async { run_suite(cfgs) })
178}