atlas_spark_bench/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3#![deny(warnings)]
4#![deny(clippy::all)]
5
6//! Atlas Spark HTTP benchmark client.
7//!
8//! Shared utilities for Criterion benchmarks and correctness tests.
9//! Expects a running Atlas Spark server (default: `http://localhost:8888`).
10
11// `gpu` wraps `cudarc` + raw CUDA driver FFI, so it's only available when
12// the cuda feature is on. The HTTP-level benchmarks below are platform-
13// agnostic and stay unconditionally exported.
14#[cfg(feature = "cuda")]
15pub mod gpu;
16
17use std::io::BufRead;
18use std::sync::Barrier;
19use std::time::{Duration, Instant};
20
21use anyhow::{Context, Result};
22use serde::{Deserialize, Serialize};
23
24// ── Configuration ──
25
26pub fn server_url() -> String {
27    std::env::var("ATLAS_BENCH_URL").unwrap_or_else(|_| "http://localhost:8888".into())
28}
29
30pub fn require_server() -> String {
31    let url = server_url();
32    match ureq::get(&format!("{url}/health")).call() {
33        Ok(resp) if resp.status() == 200 => url,
34        Ok(resp) => panic!("Server at {url} returned status {}", resp.status()),
35        Err(e) => panic!("Server not reachable at {url}: {e}. Start Atlas Spark first."),
36    }
37}
38
39// ── Request / Response types ──
40
41#[derive(Serialize)]
42struct ChatRequest<'a> {
43    model: &'a str,
44    messages: Vec<Message<'a>>,
45    max_tokens: usize,
46    temperature: f32,
47    stream: bool,
48}
49
50#[derive(Serialize)]
51struct Message<'a> {
52    role: &'a str,
53    content: &'a str,
54}
55
56#[derive(Deserialize)]
57struct ChatResponse {
58    choices: Vec<ResponseChoice>,
59    usage: ResponseUsage,
60}
61
62#[derive(Deserialize)]
63struct ResponseChoice {
64    message: ResponseMessage,
65    finish_reason: String,
66}
67
68#[derive(Deserialize)]
69struct ResponseMessage {
70    content: String,
71}
72
73#[derive(Deserialize)]
74struct ResponseUsage {
75    prompt_tokens: usize,
76    completion_tokens: usize,
77}
78
79#[derive(Deserialize)]
80struct ChunkPayload {
81    choices: Vec<ChunkChoice>,
82}
83
84#[derive(Deserialize)]
85struct ChunkChoice {
86    delta: ChunkDelta,
87    finish_reason: Option<String>,
88}
89
90#[derive(Deserialize)]
91struct ChunkDelta {
92    content: Option<String>,
93}
94
95// ── Result types ──
96
97pub struct BlockingResult {
98    pub text: String,
99    pub prompt_tokens: usize,
100    pub completion_tokens: usize,
101    pub finish_reason: String,
102    pub elapsed: Duration,
103}
104
105pub struct StreamResult {
106    pub text: String,
107    pub token_count: usize,
108    pub ttft: Duration,
109    pub decode_duration: Duration,
110    pub total_duration: Duration,
111    pub decode_tok_s: f64,
112    pub finish_reason: String,
113}
114
115// ── Blocking request ──
116
117pub fn send_blocking(url: &str, prompt: &str, max_tokens: usize) -> Result<BlockingResult> {
118    let body = ChatRequest {
119        model: "qwen3",
120        messages: vec![Message {
121            role: "user",
122            content: prompt,
123        }],
124        max_tokens,
125        temperature: 0.0,
126        stream: false,
127    };
128
129    let t_start = Instant::now();
130    let resp = ureq::post(&format!("{url}/v1/chat/completions"))
131        .send_json(&body)
132        .context("POST request failed")?;
133
134    let (_, mut body) = resp.into_parts();
135    let parsed: ChatResponse = body.read_json().context("Failed to parse response JSON")?;
136    let elapsed = t_start.elapsed();
137
138    let choice = parsed
139        .choices
140        .into_iter()
141        .next()
142        .context("No choices in response")?;
143
144    Ok(BlockingResult {
145        text: choice.message.content,
146        prompt_tokens: parsed.usage.prompt_tokens,
147        completion_tokens: parsed.usage.completion_tokens,
148        finish_reason: choice.finish_reason,
149        elapsed,
150    })
151}
152
153// ── Streaming request with timing ──
154
155pub fn send_streaming(url: &str, prompt: &str, max_tokens: usize) -> Result<StreamResult> {
156    let body = ChatRequest {
157        model: "qwen3",
158        messages: vec![Message {
159            role: "user",
160            content: prompt,
161        }],
162        max_tokens,
163        temperature: 0.0,
164        stream: true,
165    };
166
167    let t_start = Instant::now();
168    let resp = ureq::post(&format!("{url}/v1/chat/completions"))
169        .send_json(&body)
170        .context("POST streaming request failed")?;
171
172    let (_, body) = resp.into_parts();
173    let reader = std::io::BufReader::new(body.into_reader());
174    let mut token_count: usize = 0;
175    let mut t_first: Option<Instant> = None;
176    let mut t_last: Option<Instant> = None;
177    let mut finish_reason = String::new();
178    let mut text_parts = Vec::new();
179
180    for line_result in reader.lines() {
181        let line: String = line_result.context("Failed to read SSE line")?;
182        let Some(payload) = line.strip_prefix("data: ") else {
183            continue;
184        };
185        if payload == "[DONE]" {
186            break;
187        }
188        let chunk: ChunkPayload = match serde_json::from_str(payload) {
189            Ok(c) => c,
190            Err(_) => continue,
191        };
192        let Some(choice) = chunk.choices.first() else {
193            continue;
194        };
195        if let Some(ref fr) = choice.finish_reason {
196            finish_reason = fr.clone();
197        }
198        if let Some(ref content) = choice.delta.content {
199            let now = Instant::now();
200            if t_first.is_none() {
201                t_first = Some(now);
202            }
203            t_last = Some(now);
204            token_count += 1;
205            text_parts.push(content.clone());
206        }
207    }
208
209    let total_duration = t_start.elapsed();
210    let ttft = t_first.map(|tf| tf - t_start).unwrap_or(total_duration);
211    let decode_duration = match (t_first, t_last) {
212        (Some(tf), Some(tl)) if tl > tf => tl - tf,
213        _ => Duration::ZERO,
214    };
215    let decode_tok_s = if !decode_duration.is_zero() && token_count >= 2 {
216        (token_count - 1) as f64 / decode_duration.as_secs_f64()
217    } else {
218        0.0
219    };
220
221    Ok(StreamResult {
222        text: text_parts.concat(),
223        token_count,
224        ttft,
225        decode_duration,
226        total_duration,
227        decode_tok_s,
228        finish_reason,
229    })
230}
231
232// ── Concurrent streaming ──
233
234pub fn send_concurrent_streaming(
235    url: &str,
236    prompt: &str,
237    max_tokens: usize,
238    concurrency: usize,
239) -> Vec<Result<StreamResult>> {
240    let barrier = Barrier::new(concurrency);
241    std::thread::scope(|s| {
242        let handles: Vec<_> = (0..concurrency)
243            .map(|_| {
244                s.spawn(|| {
245                    barrier.wait();
246                    send_streaming(url, prompt, max_tokens)
247                })
248            })
249            .collect();
250        handles.into_iter().map(|h| h.join().unwrap()).collect()
251    })
252}
253
254// ── Prompt generators ──
255
256pub fn short_prompt() -> &'static str {
257    "What is the capital of France?"
258}
259
260pub fn medium_prompt() -> String {
261    "Explain quantum computing. ".repeat(4) + "Be concise."
262}
263
264pub fn long_prompt() -> String {
265    "Explain quantum computing. ".repeat(16) + "Be concise."
266}
267
268pub fn very_long_prompt() -> String {
269    "Explain quantum computing. ".repeat(32) + "Be concise."
270}