1use clap::{Args, ValueEnum};
18use colored::*;
19use ferrum_bench_core::{
20 arrivals::poisson_arrival_times, compute_metrics, BenchReport, Env, ItlEvidenceSource,
21 OutputTokenCountSource, QualityIssueCounts, RequestItlEvidence, RequestRecord, RunRecord,
22 Scenario, Slo, TokenLengthStats, WarmupSummary,
23};
24use ferrum_types::Result;
25use rand::{rngs::StdRng, Rng, SeedableRng};
26use serde::Deserialize;
27use sha2::{Digest, Sha256};
28use std::path::PathBuf;
29use std::sync::Arc;
30use std::time::{Duration, Instant};
31use tokio::sync::Semaphore;
32use tokio_stream::StreamExt;
33
34use crate::config::CliConfig;
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
37pub enum BenchTargetBackend {
38 Cpu,
39 Metal,
40 Cuda,
41}
42
43impl BenchTargetBackend {
44 fn as_str(self) -> &'static str {
45 match self {
46 Self::Cpu => "cpu",
47 Self::Metal => "metal",
48 Self::Cuda => "cuda",
49 }
50 }
51}
52
53#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
54pub enum BenchHttpConnectionMode {
55 #[default]
56 Pooled,
57 Fresh,
58}
59
60impl BenchHttpConnectionMode {
61 fn as_str(self) -> &'static str {
62 match self {
63 Self::Pooled => "pooled",
64 Self::Fresh => "fresh",
65 }
66 }
67}
68
69#[derive(Args, Clone)]
70pub struct BenchServeCommand {
71 #[arg(long)]
73 pub base_url: String,
74
75 #[arg(long)]
78 pub model: String,
79
80 #[arg(long)]
83 pub tokenizer: PathBuf,
84
85 #[arg(long, value_enum)]
88 pub target_backend: Option<BenchTargetBackend>,
89
90 #[arg(long, value_enum, default_value = "pooled")]
92 pub http_connection_mode: BenchHttpConnectionMode,
93
94 #[arg(long, default_value_t = 32, alias = "max-concurrency")]
98 pub concurrency: u32,
99
100 #[arg(long, value_delimiter = ',')]
103 pub concurrency_sweep: Vec<u32>,
104
105 #[arg(long)]
108 pub request_rate: Option<f64>,
109
110 #[arg(long, default_value = "random")]
115 pub dataset: String,
116
117 #[arg(long, default_value_t = 256)]
119 pub random_input_len: usize,
120
121 #[arg(long, default_value_t = 128)]
123 pub random_output_len: usize,
124
125 #[arg(long)]
128 pub ignore_eos: bool,
129
130 #[arg(long, action = clap::ArgAction::Set)]
132 pub enable_thinking: Option<bool>,
133
134 #[arg(long)]
138 pub sharegpt_path: Option<PathBuf>,
139
140 #[arg(long, default_value_t = 1024)]
142 pub shared_prefix_len: usize,
143
144 #[arg(long, default_value_t = 64)]
146 pub shared_suffix_len: usize,
147
148 #[arg(long, default_value_t = 100)]
151 pub num_prompts: u32,
152
153 #[arg(long, default_value_t = 10)]
157 pub warmup_requests: u32,
158
159 #[arg(long, default_value_t = 1)]
162 pub n_repeats: u32,
163
164 #[arg(long, value_parser = parse_slo)]
168 pub goodput: Option<Slo>,
169
170 #[arg(long, default_value_t = 600.0)]
172 pub timeout: f64,
173
174 #[arg(long)]
177 pub fail_on_error: bool,
178
179 #[arg(long)]
181 pub max_error_rate: Option<f64>,
182
183 #[arg(long)]
185 pub require_ci: bool,
186
187 #[arg(long)]
189 pub seed: Option<u64>,
190
191 #[arg(long, default_value = "json")]
196 pub output: String,
197
198 #[arg(long, alias = "result-file")]
202 pub out: Option<PathBuf>,
203
204 #[arg(long)]
207 pub hw_id: Option<String>,
208
209 #[arg(long)]
211 pub commit_sha: Option<String>,
212
213 #[arg(long)]
216 pub tag: Option<String>,
217}
218
219pub(super) fn parse_slo(s: &str) -> std::result::Result<Slo, String> {
224 let mut ttft: Option<f64> = None;
225 let mut tpot: Option<f64> = None;
226 let mut e2el: Option<f64> = None;
227 for tok in s.split(|c: char| c == ',' || c.is_whitespace()) {
228 if tok.is_empty() {
229 continue;
230 }
231 let (k, v) = tok
232 .split_once(':')
233 .ok_or_else(|| format!("bad SLO token '{tok}', expected key:value"))?;
234 let v: f64 = v.parse().map_err(|e| format!("bad SLO value '{v}': {e}"))?;
235 if !v.is_finite() || v <= 0.0 {
236 return Err(format!(
237 "bad SLO value '{v}': expected a positive finite number"
238 ));
239 }
240 match k {
241 "ttft" => ttft = Some(v),
242 "tpot" => tpot = Some(v),
243 "e2el" | "e2e" => e2el = Some(v),
244 other => return Err(format!("unknown SLO key '{other}'")),
245 }
246 }
247 Ok(Slo {
248 ttft_p99_ms: ttft.ok_or("missing ttft in --goodput")?,
249 tpot_p99_ms: tpot.ok_or("missing tpot in --goodput")?,
250 e2e_p99_ms: e2el.ok_or("missing e2el in --goodput")?,
251 })
252}
253
254#[derive(Debug, Deserialize)]
259struct OpenAiStreamChunk {
260 choices: Option<Vec<OpenAiStreamChoice>>,
261 usage: Option<OpenAiUsage>,
262 error: Option<OpenAiStreamError>,
263}
264
265#[derive(Debug, Deserialize)]
266struct OpenAiStreamError {
267 message: String,
268 #[serde(rename = "type")]
269 error_type: Option<String>,
270 code: Option<serde_json::Value>,
271}
272
273#[derive(Debug, Deserialize)]
274struct OpenAiStreamChoice {
275 delta: Option<OpenAiStreamDelta>,
276}
277
278#[derive(Debug, Deserialize)]
279struct OpenAiStreamDelta {
280 content: Option<String>,
281 reasoning: Option<String>,
282 reasoning_content: Option<String>,
283}
284
285#[derive(Debug, Deserialize)]
286struct OpenAiUsage {
287 completion_tokens: Option<u32>,
288}
289
290#[derive(Clone)]
291struct PromptCase {
292 text: String,
293 input_tokens: u32,
294 sha256: String,
295}
296
297async fn stream_one(
302 client: &reqwest::Client,
303 base_url: &str,
304 model: &str,
305 prompt: PromptCase,
306 max_tokens: usize,
307 ignore_eos: bool,
308 enable_thinking: Option<bool>,
309 timeout_s: f64,
310) -> RequestRecord {
311 let PromptCase {
312 text,
313 input_tokens,
314 sha256: prompt_sha256,
315 } = prompt;
316 let body = chat_completion_body(model, &text, max_tokens, ignore_eos, enable_thinking);
317 let start = Instant::now();
318 let mut state = StreamState::for_prompt(start, input_tokens, prompt_sha256.clone());
319
320 let resp = match client
321 .post(format!("{}/v1/chat/completions", base_url))
322 .json(&body)
323 .timeout(Duration::from_secs_f64(timeout_s))
324 .send()
325 .await
326 {
327 Ok(r) => r,
328 Err(e) => {
329 eprintln!(
330 "[err] post prompt_sha256={prompt_sha256} input_tokens={} \
331 timeout={} connect={} request={} body={} decode={}: display={e}; debug={e:?}",
332 input_tokens,
333 e.is_timeout(),
334 e.is_connect(),
335 e.is_request(),
336 e.is_body(),
337 e.is_decode(),
338 );
339 let mut quality_issues = QualityIssueCounts::default();
340 quality_issues.malformed_stream = 1;
341 return failed_record(input_tokens, start, quality_issues);
342 }
343 };
344 if !resp.status().is_success() {
345 let status = resp.status();
346 let txt = resp.text().await.unwrap_or_default();
347 eprintln!(
348 "[err] http prompt_sha256={prompt_sha256} {status}: {}",
349 clipped_debug_text(&txt, 200)
350 );
351 let mut quality_issues = QualityIssueCounts::default();
352 if status.as_u16() == 500 {
353 quality_issues.http_500 = 1;
354 }
355 if looks_like_panic(&txt) {
356 quality_issues.panic = 1;
357 }
358 return failed_record(input_tokens, start, quality_issues);
359 }
360
361 let mut stream = resp.bytes_stream();
362 let mut sse = SseLineBuffer::default();
363 while let Some(chunk) = stream.next().await {
364 let chunk = match chunk {
365 Ok(c) => c,
366 Err(e) => {
367 eprintln!("[err] stream prompt_sha256={prompt_sha256}: display={e}; debug={e:?}");
368 state.stream_error = Some(e.to_string());
369 state.quality_issues.malformed_stream = 1;
370 break;
371 }
372 };
373 let before_output_events = state.output_delta_events;
374 sse.push(&chunk, &mut state);
375 state.note_transport_chunk(
376 state
377 .output_delta_events
378 .saturating_sub(before_output_events),
379 );
380 }
381 sse.finish(&mut state);
382 state.finish()
383}
384
385#[derive(Default)]
386struct SseLineBuffer {
387 pending: Vec<u8>,
388}
389
390impl SseLineBuffer {
391 fn push(&mut self, chunk: &[u8], state: &mut StreamState) {
392 self.pending.extend_from_slice(chunk);
393 let mut consumed = 0;
394 while let Some(relative_newline) = self.pending[consumed..]
395 .iter()
396 .position(|byte| *byte == b'\n')
397 {
398 let newline = consumed + relative_newline;
399 let line = self.pending[consumed..newline].to_vec();
400 consumed = newline + 1;
401 Self::process_line(&line, state);
402 }
403 if consumed > 0 {
404 self.pending.drain(..consumed);
405 }
406 }
407
408 fn finish(&mut self, state: &mut StreamState) {
409 if !self.pending.is_empty() {
410 let line = std::mem::take(&mut self.pending);
411 Self::process_line(&line, state);
412 }
413 }
414
415 fn process_line(raw_line: &[u8], state: &mut StreamState) {
416 let raw_line = raw_line.strip_suffix(b"\r").unwrap_or(raw_line);
417 let line = match std::str::from_utf8(raw_line) {
418 Ok(line) => line.trim(),
419 Err(error) => {
420 let message = format!("invalid UTF-8 in SSE line: {error}");
421 eprintln!("[err] prompt_sha256={} {message}", state.prompt_sha256);
422 state.stream_error = Some(message);
423 state.quality_issues.malformed_stream = 1;
424 state.quality_issues.bad_output = 1;
425 return;
426 }
427 };
428 let Some(payload) = line.strip_prefix("data:").map(str::trim) else {
429 return;
430 };
431 if payload == "[DONE]" {
432 state.done_count = state
433 .done_count
434 .checked_add(1)
435 .expect("SSE done count overflow");
436 if state.done_count > 1 {
437 state.quality_issues.duplicate_done = 1;
438 }
439 return;
440 }
441 if let Err(error) = state.handle_payload(payload) {
442 eprintln!(
443 "[err] malformed sse json prompt_sha256={}: {error}",
444 state.prompt_sha256
445 );
446 state.stream_error = Some(error);
447 state.quality_issues.malformed_stream = 1;
448 }
449 }
450}
451
452fn chat_completion_body(
453 model: &str,
454 prompt_text: &str,
455 max_tokens: usize,
456 ignore_eos: bool,
457 enable_thinking: Option<bool>,
458) -> serde_json::Value {
459 let mut body = serde_json::json!({
460 "model": model,
461 "messages": [{"role": "user", "content": prompt_text}],
462 "max_tokens": max_tokens,
463 "stream": true,
464 "stream_options": {"include_usage": true},
465 "temperature": 0.0,
466 });
467 if let Some(enable_thinking) = enable_thinking {
468 body["chat_template_kwargs"] = serde_json::json!({"enable_thinking": enable_thinking});
469 }
470 if ignore_eos {
471 body["ignore_eos"] = serde_json::json!(true);
472 }
473 body
474}
475
476fn failed_record(
477 input_tokens: u32,
478 start: Instant,
479 quality_issues: QualityIssueCounts,
480) -> RequestRecord {
481 RequestRecord {
482 success: false,
483 ttft_ms: 0.0,
484 e2e_ms: start.elapsed().as_secs_f64() * 1000.0,
485 input_tokens,
486 output_tokens: 0,
487 output_token_count_source: OutputTokenCountSource::None,
488 itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
489 quality_issues,
490 itl_ms: vec![],
491 }
492}
493
494fn join_failed_record(input_tokens: u32) -> RequestRecord {
495 let mut quality_issues = QualityIssueCounts::default();
496 quality_issues.panic = 1;
497 RequestRecord {
498 success: false,
499 ttft_ms: 0.0,
500 e2e_ms: 0.0,
501 input_tokens,
502 output_tokens: 0,
503 output_token_count_source: OutputTokenCountSource::None,
504 itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
505 quality_issues,
506 itl_ms: vec![],
507 }
508}
509
510async fn collect_measured_handles(
511 handles: Vec<(u32, tokio::task::JoinHandle<RequestRecord>)>,
512) -> Vec<RequestRecord> {
513 let mut records = Vec::with_capacity(handles.len());
514 for (input_tokens, handle) in handles {
515 match handle.await {
516 Ok(record) => records.push(record),
517 Err(error) => {
518 eprintln!("[err] measured request task: {error}");
519 records.push(join_failed_record(input_tokens));
520 }
521 }
522 }
523 records
524}
525
526struct StreamState {
527 start: Instant,
528 input_tokens: u32,
529 first_token_time: Option<Instant>,
530 last_token_time: Option<Instant>,
531 output_delta_events: u32,
532 usage_completion_tokens: Option<u32>,
533 itl_ms: Vec<f64>,
534 transport_coalesced_output_chunks: u32,
535 done_count: u32,
536 stream_error: Option<String>,
537 quality_issues: QualityIssueCounts,
538 prompt_sha256: String,
539}
540
541impl StreamState {
542 #[cfg(test)]
543 fn new(start: Instant, input_tokens: u32) -> Self {
544 Self::for_prompt(start, input_tokens, "unknown".to_string())
545 }
546
547 fn for_prompt(start: Instant, input_tokens: u32, prompt_sha256: String) -> Self {
548 Self {
549 start,
550 input_tokens,
551 first_token_time: None,
552 last_token_time: None,
553 output_delta_events: 0,
554 usage_completion_tokens: None,
555 itl_ms: Vec::new(),
556 transport_coalesced_output_chunks: 0,
557 done_count: 0,
558 stream_error: None,
559 quality_issues: QualityIssueCounts::default(),
560 prompt_sha256,
561 }
562 }
563
564 fn handle_payload(&mut self, payload: &str) -> std::result::Result<(), String> {
565 let chunk: OpenAiStreamChunk =
566 serde_json::from_str(payload).map_err(|e| format!("{e}: {payload}"))?;
567 if let Some(error) = chunk.error {
568 return Err(format!(
569 "OpenAI stream error type={} code={} message={}",
570 error.error_type.as_deref().unwrap_or("unknown"),
571 error
572 .code
573 .as_ref()
574 .map(serde_json::Value::to_string)
575 .unwrap_or_else(|| "null".to_owned()),
576 error.message
577 ));
578 }
579 if let Some(usage) = chunk.usage {
580 if let Some(tokens) = usage.completion_tokens {
581 self.usage_completion_tokens = Some(tokens);
582 }
583 }
584 if let Some(choices) = chunk.choices {
585 if let Some(first) = choices.into_iter().next() {
586 if let Some(delta) = first.delta {
587 if let Some(text) = first_non_empty_delta_text(&delta) {
588 let now = Instant::now();
589 if self.first_token_time.is_none() {
590 self.first_token_time = Some(now);
591 } else if let Some(prev) = self.last_token_time {
592 self.itl_ms.push((now - prev).as_secs_f64() * 1000.0);
593 }
594 self.last_token_time = Some(now);
595 self.output_delta_events = self
596 .output_delta_events
597 .checked_add(1)
598 .expect("stream output event count overflow");
599 if let Some(reason) = bad_output_reason(text) {
600 eprintln!(
601 "[err] bad output prompt_sha256={} {reason}: {}",
602 self.prompt_sha256,
603 clipped_debug_text(text, 160)
604 );
605 self.quality_issues.bad_output = 1;
606 }
607 if looks_like_panic(text) {
608 self.quality_issues.panic = 1;
609 }
610 }
611 }
612 }
613 }
614 Ok(())
615 }
616
617 fn note_transport_chunk(&mut self, output_events: u32) {
618 if output_events > 1 {
619 self.transport_coalesced_output_chunks = self
620 .transport_coalesced_output_chunks
621 .checked_add(1)
622 .expect("coalesced output transport chunk count overflow");
623 }
624 }
625
626 fn finish(mut self) -> RequestRecord {
627 let (output_tokens, source) = match self.usage_completion_tokens {
628 Some(tokens) => (tokens, OutputTokenCountSource::Usage),
629 None if self.output_delta_events > 0 => (
630 self.output_delta_events,
631 OutputTokenCountSource::StreamChunks,
632 ),
633 None => (0, OutputTokenCountSource::None),
634 };
635 if self.done_count == 0 {
636 self.quality_issues.missing_done = 1;
637 } else if self.done_count > 1 {
638 self.quality_issues.duplicate_done = 1;
639 }
640 if output_tokens == 0 {
641 self.quality_issues.zero_output_tokens = 1;
642 }
643 let e2e_ms = self.start.elapsed().as_secs_f64() * 1000.0;
644 let ttft_ms = self
645 .first_token_time
646 .map(|t| t.duration_since(self.start).as_secs_f64() * 1000.0)
647 .unwrap_or(e2e_ms);
648 let success = self.done_count == 1
649 && output_tokens > 0
650 && self.stream_error.is_none()
651 && self.quality_issues.request_error_count() == 0;
652 let observed_intervals =
653 u32::try_from(self.itl_ms.len()).expect("stream ITL interval count overflow");
654 let itl_evidence = RequestItlEvidence::sse(
655 success,
656 self.output_delta_events,
657 self.usage_completion_tokens,
658 observed_intervals,
659 self.transport_coalesced_output_chunks,
660 );
661 RequestRecord {
662 success,
663 ttft_ms,
664 e2e_ms,
665 input_tokens: self.input_tokens,
666 output_tokens,
667 output_token_count_source: source,
668 itl_evidence,
669 quality_issues: self.quality_issues,
670 itl_ms: self.itl_ms,
671 }
672 }
673}
674
675fn first_non_empty_delta_text(delta: &OpenAiStreamDelta) -> Option<&str> {
676 delta
677 .content
678 .as_deref()
679 .filter(|s| !s.is_empty())
680 .or_else(|| delta.reasoning.as_deref().filter(|s| !s.is_empty()))
681 .or_else(|| delta.reasoning_content.as_deref().filter(|s| !s.is_empty()))
682}
683
684#[cfg(test)]
685fn has_bad_output_text(text: &str) -> bool {
686 bad_output_reason(text).is_some()
687}
688
689fn bad_output_reason(text: &str) -> Option<&'static str> {
690 const BAD_FRAGMENTS: &[(&str, &str)] = &[
691 ("<unk>", "reserved-token"),
692 ("[PAD", "reserved-token"),
693 ("<pad>", "reserved-token"),
694 ("<|endoftext|>", "reserved-token"),
695 ("<|im_start|>", "reserved-token"),
696 ("<|im_end|>", "reserved-token"),
697 ("<|reserved_special_token", "reserved-token"),
698 ("\u{fffd}", "invalid-utf8"),
699 ];
700 for (fragment, reason) in BAD_FRAGMENTS {
701 if text.contains(fragment) {
702 return Some(reason);
703 }
704 }
705 contains_mojibake_sequence(text).then_some("mojibake")
706}
707
708fn contains_mojibake_sequence(text: &str) -> bool {
709 let mut chars = text.chars().peekable();
710 while let Some(ch) = chars.next() {
711 match ch {
712 '\u{00c2}' | '\u{00c3}' => {
717 if chars.peek().is_some_and(|next| !next.is_ascii()) {
718 return true;
719 }
720 }
721 '\u{00e2}' => {
724 if chars.peek().is_some_and(|next| *next == '\u{20ac}') {
725 return true;
726 }
727 }
728 _ => {}
729 }
730 }
731 false
732}
733
734fn clipped_debug_text(text: &str, max_chars: usize) -> String {
735 text.escape_debug().take(max_chars).collect()
736}
737
738fn sha256_hex(bytes: &[u8]) -> String {
739 let mut hasher = Sha256::new();
740 hasher.update(bytes);
741 format!("{:x}", hasher.finalize())
742}
743
744fn looks_like_panic(text: &str) -> bool {
745 let lower = text.to_ascii_lowercase();
746 lower.contains("panicked at") || lower.contains("thread '") && lower.contains("panicked")
747}
748
749fn gen_random_prompt(
755 tok: &tokenizers::Tokenizer,
756 n_tokens: usize,
757 rng: &mut (impl Rng + ?Sized),
758) -> String {
759 if let Some(text) = gen_random_prompt_target_len(tok, n_tokens, rng) {
760 return text;
761 }
762 " x".repeat(n_tokens)
766}
767
768fn generated_prompt_is_safe(text: &str) -> bool {
769 !text.is_empty()
770 && !text.contains("<|")
771 && bad_output_reason(text).is_none()
772 && text
773 .chars()
774 .all(|ch| !ch.is_control() || matches!(ch, '\n' | '\r' | '\t'))
775}
776
777fn sample_safe_token_ids(
778 tok: &tokenizers::Tokenizer,
779 count: usize,
780 rng: &mut (impl Rng + ?Sized),
781 lo: u32,
782 hi: u32,
783) -> Option<Vec<u32>> {
784 let mut ids = Vec::with_capacity(count);
785 for _ in 0..count {
786 let mut selected = None;
787 for _ in 0..256 {
788 let id = rng.random_range(lo..=hi);
789 let Ok(piece) = tok.decode(&[id], false) else {
790 continue;
791 };
792 if generated_prompt_is_safe(&piece) {
793 selected = Some(id);
794 break;
795 }
796 }
797 ids.push(selected?);
798 }
799 Some(ids)
800}
801
802fn gen_random_prompt_target_len(
803 tok: &tokenizers::Tokenizer,
804 target_len: usize,
805 rng: &mut (impl Rng + ?Sized),
806) -> Option<String> {
807 if target_len == 0 {
808 return Some(String::new());
809 }
810 let vocab_size = tok.get_vocab_size(false) as u32;
811 let lo: u32 = 256.min(vocab_size.saturating_sub(1));
812 let hi: u32 = vocab_size.saturating_sub(1);
813 if hi < lo {
814 return None;
815 }
816
817 let mut sample_len = target_len;
818 let mut best_under: Option<(usize, String)> = None;
819 let mut best_any: Option<(usize, String)> = None;
820 for _ in 0..64 {
821 let ids = sample_safe_token_ids(tok, sample_len, rng, lo, hi)?;
822 let text = match tok.decode(&ids, false) {
823 Ok(text) if generated_prompt_is_safe(&text) => text,
824 _ => continue,
825 };
826 let len = match token_count(tok, &text) {
827 Some(len) => len,
828 None => continue,
829 };
830 if len == target_len {
831 return Some(text);
832 }
833 let delta = len.abs_diff(target_len);
834 if best_any
835 .as_ref()
836 .map(|(best_len, _)| delta < best_len.abs_diff(target_len))
837 .unwrap_or(true)
838 {
839 best_any = Some((len, text.clone()));
840 }
841 if len < target_len
842 && best_under
843 .as_ref()
844 .map(|(best_len, _)| len > *best_len)
845 .unwrap_or(true)
846 {
847 best_under = Some((len, text));
848 }
849
850 sample_len = if len > target_len {
853 sample_len.saturating_sub(len - target_len).max(1)
854 } else {
855 sample_len + (target_len - len).max(1)
856 };
857 }
858
859 if let Some((len, text)) = best_under {
860 return fill_random_prompt_to_len(tok, text, len, target_len, rng, lo, hi);
861 }
862 best_any.map(|(_, text)| text)
863}
864
865fn token_count(tok: &tokenizers::Tokenizer, text: &str) -> Option<usize> {
866 tok.encode(text, false).ok().map(|enc| enc.len())
867}
868
869fn fill_random_prompt_to_len(
870 tok: &tokenizers::Tokenizer,
871 mut text: String,
872 mut len: usize,
873 target_len: usize,
874 rng: &mut (impl Rng + ?Sized),
875 lo: u32,
876 hi: u32,
877) -> Option<String> {
878 for _ in 0..target_len.saturating_mul(8).max(16) {
879 if len == target_len {
880 return Some(text);
881 }
882 let next = random_one_token_extension(tok, &text, len, rng, lo, hi)?;
883 text = next.0;
884 len = next.1;
885 }
886 (len == target_len).then_some(text)
887}
888
889fn random_one_token_extension(
890 tok: &tokenizers::Tokenizer,
891 base: &str,
892 base_len: usize,
893 rng: &mut (impl Rng + ?Sized),
894 lo: u32,
895 hi: u32,
896) -> Option<(String, usize)> {
897 for _ in 0..128 {
899 let id = rng.random_range(lo..=hi);
900 if let Some(candidate) = append_decoded_piece(tok, base, base_len, id) {
901 return Some(candidate);
902 }
903 }
904 for piece in [" x", " y", " z", ".", ",", "\n"] {
907 let candidate = format!("{base}{piece}");
908 if token_count(tok, &candidate) == Some(base_len + 1) {
909 return Some((candidate, base_len + 1));
910 }
911 }
912 None
913}
914
915fn append_decoded_piece(
916 tok: &tokenizers::Tokenizer,
917 base: &str,
918 base_len: usize,
919 id: u32,
920) -> Option<(String, usize)> {
921 let piece = tok.decode(&[id], false).ok()?;
922 if !generated_prompt_is_safe(&piece) {
923 return None;
924 }
925 let candidate = format!("{base}{piece}");
926 (generated_prompt_is_safe(&candidate) && token_count(tok, &candidate) == Some(base_len + 1))
927 .then_some((candidate, base_len + 1))
928}
929
930fn build_prompts(
931 cmd: &BenchServeCommand,
932 tok: &tokenizers::Tokenizer,
933 rng: &mut (impl Rng + ?Sized),
934 count: usize,
935) -> Result<Vec<PromptCase>> {
936 match cmd.dataset.as_str() {
937 "random" => (0..count)
938 .map(|_| {
939 let text = gen_random_prompt(tok, cmd.random_input_len, rng);
940 prompt_case(tok, text)
941 })
942 .collect(),
943 "shared-prefix" => gen_shared_prefix_prompts(
944 tok,
945 count,
946 cmd.shared_prefix_len,
947 cmd.shared_suffix_len,
948 rng,
949 ),
950 "sharegpt" => {
951 let p = cmd.sharegpt_path.as_ref().ok_or_else(|| {
952 ferrum_types::FerrumError::model("--dataset sharegpt requires --sharegpt-path PATH")
953 })?;
954 load_sharegpt_prompts(p, tok, count, rng)
955 }
956 other => Err(ferrum_types::FerrumError::model(format!(
957 "unknown --dataset '{}': allowed values are random, sharegpt, shared-prefix",
958 other
959 ))),
960 }
961}
962
963fn prompt_case(tok: &tokenizers::Tokenizer, text: String) -> Result<PromptCase> {
964 let encoding = tok
965 .encode(text.as_str(), false)
966 .map_err(|e| ferrum_types::FerrumError::model(format!("tokenize generated prompt: {e}")))?;
967 let input_tokens = u32::try_from(encoding.len()).map_err(|_| {
968 ferrum_types::FerrumError::model("generated prompt token count exceeds report capacity")
969 })?;
970 let sha256 = sha256_hex(text.as_bytes());
971 Ok(PromptCase {
972 text,
973 input_tokens,
974 sha256,
975 })
976}
977
978fn gen_shared_prefix_prompts(
988 tok: &tokenizers::Tokenizer,
989 count: usize,
990 prefix_len: usize,
991 suffix_len: usize,
992 rng: &mut (impl Rng + ?Sized),
993) -> Result<Vec<PromptCase>> {
994 let prefix = gen_random_prompt(tok, prefix_len, rng);
997 (0..count)
998 .map(|_| {
999 let suffix = gen_random_prompt(tok, suffix_len, rng);
1000 prompt_case(tok, format!("{prefix}\n{suffix}"))
1004 })
1005 .collect()
1006}
1007
1008fn load_sharegpt_prompts(
1019 path: &std::path::Path,
1020 tok: &tokenizers::Tokenizer,
1021 count: usize,
1022 rng: &mut (impl Rng + ?Sized),
1023) -> Result<Vec<PromptCase>> {
1024 use std::io::BufRead;
1025 let f = std::fs::File::open(path).map_err(|e| {
1026 ferrum_types::FerrumError::model(format!("open sharegpt {}: {e}", path.display()))
1027 })?;
1028 let mut prompts: Vec<String> = Vec::new();
1029 for (idx, line) in std::io::BufReader::new(f).lines().enumerate() {
1030 let line = line.map_err(|e| {
1031 ferrum_types::FerrumError::model(format!("read line {idx} of {}: {e}", path.display()))
1032 })?;
1033 if line.trim().is_empty() {
1034 continue;
1035 }
1036 let v: serde_json::Value = match serde_json::from_str(&line) {
1037 Ok(v) => v,
1038 Err(e) => {
1039 eprintln!("[warn] sharegpt line {idx}: skip (parse error: {e})");
1040 continue;
1041 }
1042 };
1043 let prompt: Option<String> = v
1045 .get("conversations")
1046 .and_then(|c| c.as_array())
1047 .and_then(|arr| {
1048 arr.iter()
1049 .find(|t| t.get("from").and_then(|f| f.as_str()) == Some("human"))
1050 .and_then(|t| {
1051 t.get("value")
1052 .and_then(|x| x.as_str())
1053 .map(|s| s.to_string())
1054 })
1055 })
1056 .or_else(|| {
1058 v.get("input")
1059 .and_then(|s| s.as_str())
1060 .map(|s| s.to_string())
1061 });
1062 if let Some(p) = prompt {
1063 if !p.is_empty() {
1064 prompts.push(p);
1065 }
1066 }
1067 }
1068 if prompts.is_empty() {
1069 return Err(ferrum_types::FerrumError::model(format!(
1070 "sharegpt {}: no usable prompts found",
1071 path.display()
1072 )));
1073 }
1074 let mut out = Vec::with_capacity(count);
1077 for _ in 0..count {
1078 let idx = rng.random_range(0..prompts.len());
1079 out.push(prompt_case(tok, prompts[idx].clone())?);
1080 }
1081 Ok(out)
1082}
1083
1084struct RunContext {
1089 client: Arc<reqwest::Client>,
1090 base_url: Arc<String>,
1091 model: Arc<String>,
1092 max_out: usize,
1093 ignore_eos: bool,
1094 enable_thinking: Option<bool>,
1095 timeout_s: f64,
1096}
1097
1098fn summarize_warmup(
1099 expected: usize,
1100 records: &[RequestRecord],
1101 join_errors: usize,
1102) -> WarmupSummary {
1103 assert_eq!(records.len() + join_errors, expected);
1104 let completed = records.iter().filter(|record| record.success).count() as u32;
1105 let mut quality_issues = QualityIssueCounts::default();
1106 for record in records {
1107 quality_issues.add_assign(&record.quality_issues);
1108 }
1109 quality_issues.panic = quality_issues
1110 .panic
1111 .checked_add(u32::try_from(join_errors).expect("warmup join error count overflow"))
1112 .expect("warmup panic count overflow");
1113 let expected = u32::try_from(expected).expect("warmup expected count overflow");
1114 WarmupSummary {
1115 expected,
1116 completed,
1117 errored: expected
1118 .checked_sub(completed)
1119 .expect("warmup completed count exceeds expected"),
1120 quality_issues,
1121 }
1122}
1123
1124async fn run_closed_loop(
1128 ctx: &RunContext,
1129 prompts: Vec<PromptCase>,
1130 warmup_requests: u32,
1131 concurrency: u32,
1132) -> RunRecord {
1133 let n_warmup = warmup_requests as usize;
1134 let total = prompts.len();
1135 assert!(
1136 total > n_warmup,
1137 "num_prompts ({total}) must exceed warmup_requests ({n_warmup})"
1138 );
1139
1140 let warmup = {
1142 let sem = Arc::new(Semaphore::new(concurrency as usize));
1143 let mut handles = Vec::new();
1144 for prompt in prompts.iter().take(n_warmup) {
1145 let permit = sem.clone().acquire_owned().await.expect("semaphore");
1146 let ctx_c = ctx.clone_inner();
1147 let p = prompt.clone();
1148 handles.push(tokio::spawn(async move {
1149 let _g = permit;
1150 stream_one(
1151 &ctx_c.client,
1152 &ctx_c.base_url,
1153 &ctx_c.model,
1154 p,
1155 ctx_c.max_out,
1156 ctx_c.ignore_eos,
1157 ctx_c.enable_thinking,
1158 ctx_c.timeout_s,
1159 )
1160 .await
1161 }));
1162 }
1163 let mut records = Vec::with_capacity(n_warmup);
1164 let mut join_errors = 0;
1165 for handle in handles {
1166 match handle.await {
1167 Ok(record) => records.push(record),
1168 Err(_) => join_errors += 1,
1169 }
1170 }
1171 summarize_warmup(n_warmup, &records, join_errors)
1172 };
1173
1174 let sem = Arc::new(Semaphore::new(concurrency as usize));
1176 let start = Instant::now();
1177 let mut handles = Vec::with_capacity(total - n_warmup);
1178 for prompt in prompts.into_iter().skip(n_warmup) {
1179 let input_tokens = prompt.input_tokens;
1180 let permit = sem.clone().acquire_owned().await.expect("semaphore");
1181 let ctx_c = ctx.clone_inner();
1182 handles.push((
1183 input_tokens,
1184 tokio::spawn(async move {
1185 let _g = permit;
1186 stream_one(
1187 &ctx_c.client,
1188 &ctx_c.base_url,
1189 &ctx_c.model,
1190 prompt,
1191 ctx_c.max_out,
1192 ctx_c.ignore_eos,
1193 ctx_c.enable_thinking,
1194 ctx_c.timeout_s,
1195 )
1196 .await
1197 }),
1198 ));
1199 }
1200 let records = collect_measured_handles(handles).await;
1201 let duration_s = start.elapsed().as_secs_f64();
1202 RunRecord {
1203 records,
1204 expected_requests: u32::try_from(total - n_warmup)
1205 .expect("measured request count overflow"),
1206 duration_s,
1207 warmup,
1208 }
1209}
1210
1211async fn run_open_loop(
1214 ctx: &RunContext,
1215 prompts: Vec<PromptCase>,
1216 warmup_requests: u32,
1217 rate: f64,
1218) -> RunRecord {
1219 let n_warmup = warmup_requests as usize;
1220 let total = prompts.len();
1221 assert!(total > n_warmup);
1222
1223 let mut warmup_records = Vec::with_capacity(n_warmup);
1225 for prompt in prompts.iter().take(n_warmup) {
1226 warmup_records.push(
1227 stream_one(
1228 &ctx.client,
1229 &ctx.base_url,
1230 &ctx.model,
1231 prompt.clone(),
1232 ctx.max_out,
1233 ctx.ignore_eos,
1234 ctx.enable_thinking,
1235 ctx.timeout_s,
1236 )
1237 .await,
1238 );
1239 }
1240 let warmup = summarize_warmup(n_warmup, &warmup_records, 0);
1241
1242 let mut rng = rand::rng();
1244 let measurement_count = total - n_warmup;
1245 let schedule = poisson_arrival_times(rate, measurement_count, &mut rng);
1246
1247 let start = Instant::now();
1248 let mut handles = Vec::with_capacity(measurement_count);
1249 for (i, prompt) in prompts.into_iter().skip(n_warmup).enumerate() {
1250 let target = schedule[i];
1251 let now = start.elapsed().as_secs_f64();
1252 if target > now {
1253 tokio::time::sleep(Duration::from_secs_f64(target - now)).await;
1254 }
1255 let ctx_c = ctx.clone_inner();
1256 let input_tokens = prompt.input_tokens;
1257 handles.push((
1258 input_tokens,
1259 tokio::spawn(async move {
1260 stream_one(
1261 &ctx_c.client,
1262 &ctx_c.base_url,
1263 &ctx_c.model,
1264 prompt,
1265 ctx_c.max_out,
1266 ctx_c.ignore_eos,
1267 ctx_c.enable_thinking,
1268 ctx_c.timeout_s,
1269 )
1270 .await
1271 }),
1272 ));
1273 }
1274 let records = collect_measured_handles(handles).await;
1275 let duration_s = start.elapsed().as_secs_f64();
1276 RunRecord {
1277 records,
1278 expected_requests: u32::try_from(measurement_count)
1279 .expect("measured request count overflow"),
1280 duration_s,
1281 warmup,
1282 }
1283}
1284
1285impl RunContext {
1286 fn clone_inner(&self) -> Self {
1287 Self {
1288 client: self.client.clone(),
1289 base_url: self.base_url.clone(),
1290 model: self.model.clone(),
1291 max_out: self.max_out,
1292 ignore_eos: self.ignore_eos,
1293 enable_thinking: self.enable_thinking,
1294 timeout_s: self.timeout_s,
1295 }
1296 }
1297}
1298
1299fn build_env(cmd: &BenchServeCommand, features: Vec<String>) -> Env {
1304 let commit_sha = cmd
1305 .commit_sha
1306 .clone()
1307 .or_else(|| {
1308 std::process::Command::new("git")
1309 .args(["rev-parse", "--short", "HEAD"])
1310 .output()
1311 .ok()
1312 .and_then(|o| String::from_utf8(o.stdout).ok())
1313 .map(|s| s.trim().to_string())
1314 })
1315 .unwrap_or_else(|| "unknown".to_string());
1316
1317 let mut env = Env::capture_minimal(commit_sha, features);
1318 env.http_connection_mode = Some(cmd.http_connection_mode.as_str().to_string());
1319 if let Some(hw) = cmd.hw_id.clone() {
1320 env.hw_id = hw;
1321 }
1322 env
1323}
1324
1325fn detect_features() -> Vec<String> {
1326 let mut v = Vec::new();
1327 #[cfg(feature = "metal")]
1328 v.push("metal".to_string());
1329 #[cfg(feature = "cuda")]
1330 v.push("cuda".to_string());
1331 #[cfg(feature = "vllm-marlin")]
1332 v.push("vllm-marlin".to_string());
1333 #[cfg(feature = "vllm-moe-marlin")]
1334 v.push("vllm-moe-marlin".to_string());
1335 #[cfg(feature = "vllm-paged-attn-v2")]
1336 v.push("vllm-paged-attn-v2".to_string());
1337 #[cfg(feature = "triton-kernels")]
1338 v.push("triton-kernels".to_string());
1339 v.sort();
1340 v.dedup();
1341 v
1342}
1343
1344#[derive(Clone, Copy)]
1349enum Cell {
1350 Closed(u32),
1351 Open(f64),
1352}
1353
1354async fn execute_cell(
1355 cmd: &BenchServeCommand,
1356 ctx: &RunContext,
1357 cell: Cell,
1358) -> Result<BenchReport> {
1359 let backend = cmd
1363 .target_backend
1364 .map(BenchTargetBackend::as_str)
1365 .unwrap_or_else(|| {
1366 if cfg!(feature = "cuda") {
1367 "cuda"
1368 } else if cfg!(feature = "metal") {
1369 "metal"
1370 } else {
1371 "cpu"
1372 }
1373 });
1374
1375 let tokenizer_path = cmd.tokenizer.join("tokenizer.json");
1376 let tok = tokenizers::Tokenizer::from_file(&tokenizer_path).map_err(|e| {
1377 ferrum_types::FerrumError::model(format!(
1378 "Load tokenizer at {}: {}",
1379 tokenizer_path.display(),
1380 e
1381 ))
1382 })?;
1383 let total_prompts_u32 = cmd
1384 .num_prompts
1385 .checked_add(cmd.warmup_requests)
1386 .ok_or_else(|| {
1387 ferrum_types::FerrumError::model("num_prompts + warmup_requests overflow")
1388 })?;
1389 let total_prompts = usize::try_from(total_prompts_u32)
1390 .map_err(|_| ferrum_types::FerrumError::model("prompt count exceeds platform capacity"))?;
1391
1392 let mut runs: Vec<RunRecord> = Vec::with_capacity(cmd.n_repeats as usize);
1393 let mut actual_input_lengths: Vec<u32> = Vec::new();
1394 let mut actual_input_tokens_per_request: Vec<Vec<u32>> =
1395 Vec::with_capacity(cmd.n_repeats as usize);
1396 for repeat_idx in 0..cmd.n_repeats {
1397 let mut seeded_rng;
1398 let mut thread_rng;
1399 let rng: &mut dyn rand::RngCore = if let Some(seed) = cmd.seed {
1400 seeded_rng =
1401 StdRng::seed_from_u64(seed ^ ((repeat_idx as u64) << 32) ^ cell_seed(cell));
1402 &mut seeded_rng
1403 } else {
1404 thread_rng = rand::rng();
1405 &mut thread_rng
1406 };
1407 let prompts = build_prompts(cmd, &tok, rng, total_prompts)?;
1408 let measured_input_lengths: Vec<u32> = prompts
1409 .iter()
1410 .skip(cmd.warmup_requests as usize)
1411 .map(|p| p.input_tokens)
1412 .collect();
1413 actual_input_lengths.extend(measured_input_lengths.iter().copied());
1414 actual_input_tokens_per_request.push(measured_input_lengths);
1415 eprintln!(
1416 "{}",
1417 format!(
1418 " cell {} — repeat {}/{} ({} prompts including {} warmup)",
1419 cell_label(cell),
1420 repeat_idx + 1,
1421 cmd.n_repeats,
1422 total_prompts,
1423 cmd.warmup_requests
1424 )
1425 .dimmed()
1426 );
1427 let run = match cell {
1428 Cell::Closed(c) => run_closed_loop(ctx, prompts, cmd.warmup_requests, c).await,
1429 Cell::Open(r) => run_open_loop(ctx, prompts, cmd.warmup_requests, r).await,
1430 };
1431 eprintln!(
1432 " {} completed / {} errored / {:.1}s",
1433 run.n_completed(),
1434 run.n_errored(),
1435 run.duration_s
1436 );
1437 runs.push(run);
1438 }
1439 let requested_input_len = requested_input_len(cmd)?;
1440 let requested_output_len = u32::try_from(cmd.random_output_len).map_err(|_| {
1441 ferrum_types::FerrumError::model("random output length exceeds report capacity")
1442 })?;
1443 let actual_input_tokens = input_token_stats(&actual_input_lengths, requested_input_len);
1444 let token_count_source = output_token_count_source_from_runs(&runs);
1445
1446 let env = build_env(cmd, detect_features());
1447 let slo = cmd.goodput.unwrap_or_else(Slo::unbounded);
1448 let model_field = match &cmd.tag {
1449 Some(t) => format!("{}#{}", cmd.model, t),
1450 None => cmd.model.clone(),
1451 };
1452
1453 let (scenario, concurrency, request_rate) = match cell {
1454 Cell::Closed(c) => (Scenario::ClosedLoop, Some(c), None),
1455 Cell::Open(r) => (Scenario::OpenLoop, None, Some(r)),
1456 };
1457
1458 let mut report = compute_metrics(
1459 model_field,
1460 backend.to_string(),
1461 scenario,
1462 concurrency,
1463 request_rate,
1464 requested_input_len,
1465 requested_output_len,
1466 cmd.warmup_requests,
1467 slo,
1468 runs,
1469 env,
1470 );
1471 report.actual_input_tokens = Some(actual_input_tokens);
1472 report.actual_input_tokens_per_request = Some(actual_input_tokens_per_request);
1473 report.output_token_count_source = Some(token_count_source);
1474 Ok(report)
1475}
1476
1477fn requested_input_len(cmd: &BenchServeCommand) -> Result<u32> {
1478 match cmd.dataset.as_str() {
1479 "shared-prefix" => cmd
1480 .shared_prefix_len
1481 .checked_add(cmd.shared_suffix_len)
1482 .and_then(|value| u32::try_from(value).ok())
1483 .ok_or_else(|| {
1484 ferrum_types::FerrumError::model(
1485 "shared prefix + suffix length exceeds report capacity",
1486 )
1487 }),
1488 _ => u32::try_from(cmd.random_input_len).map_err(|_| {
1489 ferrum_types::FerrumError::model("random input length exceeds report capacity")
1490 }),
1491 }
1492}
1493
1494fn input_token_stats(lengths: &[u32], requested: u32) -> TokenLengthStats {
1495 let min = lengths.iter().copied().min().unwrap_or(0);
1496 let max = lengths.iter().copied().max().unwrap_or(0);
1497 let mean = if lengths.is_empty() {
1498 0.0
1499 } else {
1500 lengths.iter().map(|&n| n as f64).sum::<f64>() / lengths.len() as f64
1501 };
1502 TokenLengthStats {
1503 requested,
1504 min,
1505 max,
1506 mean,
1507 }
1508}
1509
1510fn output_token_count_source_from_runs(runs: &[RunRecord]) -> String {
1511 let mut saw_usage = false;
1512 let mut saw_stream_chunks = false;
1513 let mut saw_none = false;
1514 for record in runs.iter().flat_map(|run| &run.records) {
1515 match record.output_token_count_source {
1516 OutputTokenCountSource::Usage => saw_usage = true,
1517 OutputTokenCountSource::StreamChunks => saw_stream_chunks = true,
1518 OutputTokenCountSource::None => saw_none = true,
1519 }
1520 }
1521 match (saw_usage, saw_stream_chunks, saw_none) {
1522 (true, false, false) => "usage".to_string(),
1523 (false, true, false) => "stream_chunks".to_string(),
1524 (false, false, true) => "none".to_string(),
1525 _ => "mixed".to_string(),
1526 }
1527}
1528
1529fn cell_seed(cell: Cell) -> u64 {
1530 match cell {
1531 Cell::Closed(c) => 0xC10C_ED00_0000_0000u64 ^ c as u64,
1532 Cell::Open(r) => 0x0FEE_D000_0000_0000u64 ^ r.to_bits(),
1533 }
1534}
1535
1536fn cell_label(cell: Cell) -> String {
1537 match cell {
1538 Cell::Closed(c) => format!("closed_loop c={c}"),
1539 Cell::Open(r) => format!("open_loop rate={r}"),
1540 }
1541}
1542
1543pub async fn execute(cmd: BenchServeCommand, _cfg: CliConfig) -> Result<()> {
1548 validate_command(&cmd)?;
1549 eprintln!(
1550 "{}",
1551 format!(
1552 "ferrum bench-serve — dataset={} num_prompts={} warmup={} n_repeats={}",
1553 cmd.dataset, cmd.num_prompts, cmd.warmup_requests, cmd.n_repeats
1554 )
1555 .dimmed()
1556 );
1557 if cmd.n_repeats < 3 {
1558 eprintln!(
1559 "{}",
1560 " [warn] n_repeats < 3 — emitting mean only, no stddev/CI95 (PLAYBOOK § 0.4)".yellow()
1561 );
1562 }
1563
1564 let cells: Vec<Cell> = if let Some(rate) = cmd.request_rate {
1566 vec![Cell::Open(rate)]
1567 } else if !cmd.concurrency_sweep.is_empty() {
1568 cmd.concurrency_sweep
1569 .iter()
1570 .copied()
1571 .map(Cell::Closed)
1572 .collect()
1573 } else {
1574 vec![Cell::Closed(cmd.concurrency)]
1575 };
1576
1577 let pool_max_idle_per_host = match cmd.http_connection_mode {
1578 BenchHttpConnectionMode::Pooled => 64,
1579 BenchHttpConnectionMode::Fresh => 0,
1580 };
1581 let client = Arc::new(
1582 reqwest::Client::builder()
1583 .pool_max_idle_per_host(pool_max_idle_per_host)
1584 .build()
1585 .map_err(|e| ferrum_types::FerrumError::model(format!("reqwest client: {e}")))?,
1586 );
1587 let ctx = RunContext {
1588 client,
1589 base_url: Arc::new(cmd.base_url.clone()),
1590 model: Arc::new(cmd.model.clone()),
1591 max_out: cmd.random_output_len,
1592 ignore_eos: cmd.ignore_eos,
1593 enable_thinking: cmd.enable_thinking,
1594 timeout_s: cmd.timeout,
1595 };
1596
1597 let mut reports: Vec<BenchReport> = Vec::with_capacity(cells.len());
1598 for cell in cells {
1599 eprintln!("{}", format!("→ {}", cell_label(cell)).bold());
1600 let r = execute_cell(&cmd, &ctx, cell).await?;
1601 emit_summary_line(&r);
1602 reports.push(r);
1603 }
1604
1605 emit_then_enforce_error_policy(&cmd, &reports)
1606}
1607
1608fn emit_then_enforce_error_policy(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1609 emit_reports(cmd, reports)?;
1612
1613 ferrum_bench_core::trace::flush_global_trace();
1615
1616 enforce_error_policy(cmd, reports)
1617}
1618
1619fn emit_reports(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1620 match cmd.output.as_str() {
1621 "json" => emit_json(cmd, reports)?,
1622 "jsonl" => emit_jsonl(cmd, reports)?,
1623 "md" => emit_markdown(cmd, reports)?,
1624 other => {
1625 return Err(ferrum_types::FerrumError::model(format!(
1626 "unknown --output '{other}': allowed values are json, jsonl, md"
1627 )))
1628 }
1629 }
1630 Ok(())
1631}
1632
1633fn validate_command(cmd: &BenchServeCommand) -> Result<()> {
1634 if let Some(rate) = cmd.request_rate {
1635 if rate <= 0.0 || !rate.is_finite() {
1636 return Err(ferrum_types::FerrumError::model(
1637 "--request-rate must be a positive finite number",
1638 ));
1639 }
1640 }
1641 if cmd.timeout <= 0.0
1642 || !cmd.timeout.is_finite()
1643 || Duration::try_from_secs_f64(cmd.timeout).is_err()
1644 {
1645 return Err(ferrum_types::FerrumError::model(
1646 "--timeout must be a positive finite duration",
1647 ));
1648 }
1649 if cmd.concurrency == 0 {
1650 return Err(ferrum_types::FerrumError::model(
1651 "--concurrency must be > 0",
1652 ));
1653 }
1654 if cmd.num_prompts == 0 {
1655 return Err(ferrum_types::FerrumError::model(
1656 "--num-prompts must be > 0",
1657 ));
1658 }
1659 if cmd.n_repeats == 0 {
1660 return Err(ferrum_types::FerrumError::model("--n-repeats must be > 0"));
1661 }
1662 if cmd.random_input_len == 0 {
1663 return Err(ferrum_types::FerrumError::model(
1664 "--random-input-len must be > 0",
1665 ));
1666 }
1667 if cmd.random_output_len == 0 {
1668 return Err(ferrum_types::FerrumError::model(
1669 "--random-output-len must be > 0",
1670 ));
1671 }
1672 u32::try_from(cmd.random_output_len).map_err(|_| {
1673 ferrum_types::FerrumError::model("--random-output-len exceeds report capacity")
1674 })?;
1675 requested_input_len(cmd)?;
1676 if cmd.goodput.is_some_and(|slo| !slo.is_valid()) {
1677 return Err(ferrum_types::FerrumError::model(
1678 "--goodput values must be positive finite numbers",
1679 ));
1680 }
1681 if cmd.require_ci && cmd.n_repeats < 3 {
1682 return Err(ferrum_types::FerrumError::model(
1683 "--require-ci requires --n-repeats >= 3",
1684 ));
1685 }
1686 if let Some(max_error_rate) = cmd.max_error_rate {
1687 if !(0.0..=1.0).contains(&max_error_rate) || !max_error_rate.is_finite() {
1688 return Err(ferrum_types::FerrumError::model(
1689 "--max-error-rate must be in [0.0, 1.0]",
1690 ));
1691 }
1692 }
1693 for cell in &cmd.concurrency_sweep {
1694 if *cell == 0 {
1695 return Err(ferrum_types::FerrumError::model(
1696 "--concurrency-sweep values must be > 0",
1697 ));
1698 }
1699 }
1700 let _total_prompts = cmd
1701 .num_prompts
1702 .checked_add(cmd.warmup_requests)
1703 .ok_or_else(|| {
1704 ferrum_types::FerrumError::model("num_prompts + warmup_requests overflow")
1705 })?;
1706 Ok(())
1707}
1708
1709fn enforce_error_policy(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1710 let max_error_rate = cmd.max_error_rate.unwrap_or(0.0);
1711 for report in reports {
1712 let warmup_errored: u64 = report
1713 .repeat_metrics
1714 .iter()
1715 .map(|repeat| repeat.warmup_errored as u64)
1716 .sum();
1717 let warmup_has_quality_issue = report
1718 .repeat_metrics
1719 .iter()
1720 .any(|repeat| repeat.warmup_quality_issues != QualityIssueCounts::default());
1721 if warmup_errored > 0 || warmup_has_quality_issue {
1722 return Err(ferrum_types::FerrumError::model(format!(
1723 "bench-serve warmup failed for {}: {} errored request(s)",
1724 report.model, warmup_errored
1725 )));
1726 }
1727 if !cmd.fail_on_error && cmd.max_error_rate.is_none() {
1728 continue;
1729 }
1730
1731 let completed: u64 = report
1732 .completed_per_run
1733 .iter()
1734 .map(|value| *value as u64)
1735 .sum();
1736 let errored: u64 = report
1737 .errored_per_run
1738 .iter()
1739 .map(|value| *value as u64)
1740 .sum();
1741 let total = completed
1742 .checked_add(errored)
1743 .ok_or_else(|| ferrum_types::FerrumError::model("measured request count overflow"))?;
1744 let error_rate = if total == 0 {
1745 1.0
1746 } else {
1747 errored as f64 / total as f64
1748 };
1749 if error_rate > max_error_rate {
1750 return Err(ferrum_types::FerrumError::model(format!(
1751 "bench-serve error rate {:.4} exceeds max {:.4} for {}",
1752 error_rate, max_error_rate, report.model
1753 )));
1754 }
1755 }
1756 Ok(())
1757}
1758
1759fn emit_jsonl(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1760 use std::io::Write as _;
1761 let out_path = cmd.out.as_ref().ok_or_else(|| {
1762 ferrum_types::FerrumError::model("--output jsonl requires --out PATH (append-mode log)")
1763 })?;
1764 let mut f = std::fs::OpenOptions::new()
1765 .create(true)
1766 .append(true)
1767 .open(out_path)
1768 .map_err(|e| {
1769 ferrum_types::FerrumError::model(format!("open {}: {e}", out_path.display()))
1770 })?;
1771 for r in reports {
1772 let line = serde_json::to_string(r).expect("serialize");
1773 writeln!(f, "{}", line)
1774 .map_err(|e| ferrum_types::FerrumError::model(format!("write jsonl: {e}")))?;
1775 }
1776 eprintln!(
1777 "\n→ appended {} record(s) to {}",
1778 reports.len(),
1779 out_path.display()
1780 );
1781 Ok(())
1782}
1783
1784fn emit_markdown(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1785 use ferrum_bench_core::report::{render_single, render_sweep};
1786 let md = if reports.len() == 1 {
1787 render_single(&reports[0])
1788 } else {
1789 render_sweep(reports)
1790 };
1791 if let Some(out) = cmd.out.as_ref() {
1792 std::fs::write(out, &md).map_err(|e| {
1793 ferrum_types::FerrumError::model(format!("write {}: {e}", out.display()))
1794 })?;
1795 eprintln!("\n→ wrote {}", out.display());
1796 } else {
1797 println!("{}", md);
1798 }
1799 Ok(())
1800}
1801
1802fn emit_summary_line(r: &BenchReport) {
1803 let scenario_str = match r.scenario {
1804 Scenario::ClosedLoop => format!("c={}", r.concurrency.unwrap_or(0)),
1805 Scenario::OpenLoop => format!("rate={}", r.request_rate.unwrap_or(0.0)),
1806 Scenario::SharedPrefix => "shared_prefix".to_string(),
1807 Scenario::Cli => "cli".to_string(),
1808 };
1809 let ci = if r.n_repeats >= 3 {
1810 format!(" (n_repeats={}, ± = ci95_hw)", r.n_repeats)
1811 } else {
1812 format!(" (n_repeats={}, no CI)", r.n_repeats)
1813 };
1814 eprintln!(" {} {}{}", "summary".bold(), scenario_str, ci);
1815 fmt_metric("TTFT_ms ", &r.ttft_ms, r.n_repeats);
1816 fmt_metric("TPOT_ms ", &r.tpot_ms, r.n_repeats);
1817 if r.has_complete_itl_evidence() {
1818 fmt_metric("ITL_ms ", &r.itl_ms, r.n_repeats);
1819 } else {
1820 eprintln!(" ITL_ms unavailable");
1821 }
1822 let thr = &r.output_throughput_tps;
1823 let good = &r.goodput_rps;
1824 if r.n_repeats >= 3 {
1825 eprintln!(
1826 " throughput {:.1} ± {:.1} tok/s",
1827 thr.mean, thr.ci95_hw
1828 );
1829 eprintln!(
1830 " goodput {:.2} ± {:.2} req/s",
1831 good.mean, good.ci95_hw
1832 );
1833 } else {
1834 eprintln!(" throughput {:.1} tok/s", thr.mean);
1835 eprintln!(" goodput {:.2} req/s", good.mean);
1836 }
1837}
1838
1839fn fmt_metric(name: &str, m: &ferrum_bench_core::MetricSet, n_repeats: u32) {
1840 if n_repeats >= 3 {
1841 eprintln!(
1842 " {} p50={:.1}±{:.1} p95={:.1}±{:.1} p99={:.1}±{:.1}",
1843 name, m.p50.mean, m.p50.ci95_hw, m.p95.mean, m.p95.ci95_hw, m.p99.mean, m.p99.ci95_hw
1844 );
1845 } else {
1846 eprintln!(
1847 " {} p50={:.1} p95={:.1} p99={:.1}",
1848 name, m.p50.mean, m.p95.mean, m.p99.mean
1849 );
1850 }
1851}
1852
1853fn emit_json(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1854 let value = if reports.len() == 1 {
1855 serde_json::to_value(&reports[0]).expect("serialize")
1856 } else {
1857 serde_json::to_value(reports).expect("serialize")
1858 };
1859 let pretty = serde_json::to_string_pretty(&value).expect("pretty");
1860 if let Some(out) = cmd.out.as_ref() {
1861 std::fs::write(out, &pretty).map_err(|e| {
1862 ferrum_types::FerrumError::model(format!("write {}: {e}", out.display()))
1863 })?;
1864 eprintln!("\n→ wrote {}", out.display());
1865 } else {
1866 println!("{}", pretty);
1867 }
1868 Ok(())
1869}
1870
1871#[cfg(test)]
1872mod tests {
1873 use super::*;
1874 use ferrum_bench_core::ItlEligibility;
1875
1876 fn parse_sse_chunks<'a>(chunks: impl IntoIterator<Item = &'a [u8]>) -> RequestRecord {
1877 let mut state = StreamState::new(Instant::now(), 7);
1878 let mut parser = SseLineBuffer::default();
1879 for chunk in chunks {
1880 let before_output_events = state.output_delta_events;
1881 parser.push(chunk, &mut state);
1882 let output_events = state
1883 .output_delta_events
1884 .saturating_sub(before_output_events);
1885 state.note_transport_chunk(output_events);
1886 }
1887 parser.finish(&mut state);
1888 state.finish()
1889 }
1890
1891 fn stream_semantics(
1892 record: &RequestRecord,
1893 ) -> (
1894 bool,
1895 u32,
1896 OutputTokenCountSource,
1897 QualityIssueCounts,
1898 RequestItlEvidence,
1899 ) {
1900 (
1901 record.success,
1902 record.output_tokens,
1903 record.output_token_count_source,
1904 record.quality_issues.clone(),
1905 record.itl_evidence.clone(),
1906 )
1907 }
1908
1909 #[test]
1910 fn slo_parses_space_separated() {
1911 let s = parse_slo("ttft:500 tpot:50 e2el:30000").unwrap();
1912 assert_eq!(s.ttft_p99_ms, 500.0);
1913 assert_eq!(s.tpot_p99_ms, 50.0);
1914 assert_eq!(s.e2e_p99_ms, 30000.0);
1915 }
1916
1917 #[test]
1918 fn slo_parses_comma_separated() {
1919 let s = parse_slo("ttft:500,tpot:50,e2el:30000").unwrap();
1920 assert_eq!(s.ttft_p99_ms, 500.0);
1921 }
1922
1923 #[test]
1924 fn slo_accepts_e2e_alias() {
1925 let s = parse_slo("ttft:1 tpot:2 e2e:3").unwrap();
1926 assert_eq!(s.e2e_p99_ms, 3.0);
1927 }
1928
1929 #[test]
1930 fn slo_rejects_missing_key() {
1931 assert!(parse_slo("ttft:500 tpot:50").is_err());
1932 }
1933
1934 #[test]
1935 fn slo_rejects_unknown_key() {
1936 assert!(parse_slo("ttft:500 tpot:50 e2el:30000 bogus:1").is_err());
1937 }
1938
1939 #[test]
1940 fn slo_rejects_nonfinite_and_nonpositive_values() {
1941 for value in ["NaN", "inf", "0", "-1"] {
1942 assert!(
1943 parse_slo(&format!("ttft:{value} tpot:50 e2el:30000")).is_err(),
1944 "SLO value {value} should be rejected"
1945 );
1946 }
1947 }
1948
1949 #[test]
1950 fn chat_completion_body_omits_ignore_eos_by_default() {
1951 let body = chat_completion_body("model", "prompt", 128, false, None);
1952 assert_eq!(body["model"], serde_json::json!("model"));
1953 assert_eq!(body["max_tokens"], serde_json::json!(128));
1954 assert_eq!(body["stream"], serde_json::json!(true));
1955 assert!(body.get("chat_template_kwargs").is_none());
1956 assert_eq!(
1957 body["stream_options"]["include_usage"],
1958 serde_json::json!(true)
1959 );
1960 assert!(body.get("ignore_eos").is_none());
1961 }
1962
1963 #[test]
1964 fn chat_completion_body_sends_ignore_eos_when_requested() {
1965 let body = chat_completion_body("model", "prompt", 128, true, None);
1966 assert_eq!(body["ignore_eos"], serde_json::json!(true));
1967 }
1968
1969 #[test]
1970 fn chat_completion_body_sends_typed_thinking_values() {
1971 let disabled = chat_completion_body("model", "prompt", 128, false, Some(false));
1972 assert_eq!(
1973 disabled["chat_template_kwargs"]["enable_thinking"],
1974 serde_json::json!(false)
1975 );
1976 let enabled = chat_completion_body("model", "prompt", 128, false, Some(true));
1977 assert_eq!(
1978 enabled["chat_template_kwargs"]["enable_thinking"],
1979 serde_json::json!(true)
1980 );
1981 }
1982
1983 #[test]
1984 fn enable_thinking_cli_is_tri_state() {
1985 use clap::Parser as _;
1986
1987 #[derive(clap::Parser)]
1988 struct TestCli {
1989 #[command(flatten)]
1990 command: BenchServeCommand,
1991 }
1992
1993 let base = [
1994 "test",
1995 "--base-url",
1996 "http://127.0.0.1:8000",
1997 "--model",
1998 "model",
1999 "--tokenizer",
2000 ".",
2001 ];
2002 let absent = TestCli::parse_from(base).command;
2003 assert_eq!(absent.enable_thinking, None);
2004 assert_eq!(absent.http_connection_mode, BenchHttpConnectionMode::Pooled);
2005
2006 let disabled =
2007 TestCli::parse_from(base.into_iter().chain(["--enable-thinking", "false"])).command;
2008 assert_eq!(disabled.enable_thinking, Some(false));
2009
2010 let enabled =
2011 TestCli::parse_from(base.into_iter().chain(["--enable-thinking", "true"])).command;
2012 assert_eq!(enabled.enable_thinking, Some(true));
2013
2014 let metal =
2015 TestCli::parse_from(base.into_iter().chain(["--target-backend", "metal"])).command;
2016 assert_eq!(metal.target_backend, Some(BenchTargetBackend::Metal));
2017
2018 let fresh =
2019 TestCli::parse_from(base.into_iter().chain(["--http-connection-mode", "fresh"]))
2020 .command;
2021 assert_eq!(fresh.http_connection_mode, BenchHttpConnectionMode::Fresh);
2022 }
2023
2024 #[test]
2025 fn bench_env_locks_http_connection_mode() {
2026 let pooled_cmd = test_command();
2027 let pooled = build_env(&pooled_cmd, vec![]);
2028 assert_eq!(pooled.http_connection_mode.as_deref(), Some("pooled"));
2029
2030 let mut fresh_cmd = pooled_cmd;
2031 fresh_cmd.http_connection_mode = BenchHttpConnectionMode::Fresh;
2032 let fresh = build_env(&fresh_cmd, vec![]);
2033 assert_eq!(fresh.http_connection_mode.as_deref(), Some("fresh"));
2034 assert_ne!(pooled.hash(), fresh.hash());
2035 }
2036
2037 #[test]
2038 fn incremental_sse_utf8_semantics_are_invariant_to_every_byte_cut() {
2039 let stream = concat!(
2040 "data: {\"choices\":[{\"delta\":{\"content\":\"你好🙂\"}}],\"usage\":null}\n\n",
2041 "data: {\"choices\":[],\"usage\":{\"completion_tokens\":1}}\n\n",
2042 "data: [DONE]\n\n",
2043 )
2044 .as_bytes();
2045 let baseline = parse_sse_chunks([stream]);
2046 assert!(baseline.success);
2047 assert_eq!(baseline.quality_issues, QualityIssueCounts::default());
2048
2049 for cut in 0..=stream.len() {
2050 let split = parse_sse_chunks([&stream[..cut], &stream[cut..]]);
2051 assert_eq!(
2052 stream_semantics(&split),
2053 stream_semantics(&baseline),
2054 "SSE semantics changed at byte cut {cut}"
2055 );
2056 }
2057
2058 let bytewise = parse_sse_chunks(stream.chunks(1));
2059 assert_eq!(stream_semantics(&bytewise), stream_semantics(&baseline));
2060 }
2061
2062 #[test]
2063 fn transport_coalescing_is_diagnostic_not_stream_bulk_failure() {
2064 const FIRST: &[u8] =
2065 b"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}],\"usage\":null}\n\n";
2066 const SECOND: &[u8] =
2067 b"data: {\"choices\":[{\"delta\":{\"content\":\"b\"}}],\"usage\":null}\n\n";
2068 const TAIL: &[u8] =
2069 b"data: {\"choices\":[],\"usage\":{\"completion_tokens\":2}}\n\ndata: [DONE]\n\n";
2070 let together_bytes = [FIRST, SECOND, TAIL].concat();
2071 let together = parse_sse_chunks([together_bytes.as_slice()]);
2072 assert!(together.success);
2073 assert_eq!(together.quality_issues.stream_bulk_flush, 0);
2074 assert_eq!(together.itl_evidence.transport_coalesced_output_chunks, 1);
2075 assert_eq!(
2076 together.itl_evidence.eligibility,
2077 ItlEligibility::TransportCoalesced
2078 );
2079
2080 let separated = parse_sse_chunks([FIRST, SECOND, TAIL]);
2081 assert!(separated.success);
2082 assert_eq!(separated.quality_issues, QualityIssueCounts::default());
2083 assert_eq!(separated.itl_evidence.transport_coalesced_output_chunks, 0);
2084 assert_eq!(separated.itl_evidence.eligibility, ItlEligibility::Eligible);
2085 }
2086
2087 #[test]
2088 fn stream_done_with_usage_succeeds() {
2089 let mut state = StreamState::new(Instant::now(), 7);
2090 state
2091 .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}],"usage":null}"#)
2092 .unwrap();
2093 state
2094 .handle_payload(r#"{"choices":[],"usage":{"completion_tokens":3}}"#)
2095 .unwrap();
2096 state.done_count = 1;
2097 let record = state.finish();
2098 assert!(record.success);
2099 assert_eq!(record.output_tokens, 3);
2100 assert_eq!(
2101 record.output_token_count_source,
2102 OutputTokenCountSource::Usage
2103 );
2104 assert_eq!(
2105 record.itl_evidence.eligibility,
2106 ItlEligibility::EventUsageMismatch
2107 );
2108 assert_eq!(record.itl_evidence.output_events, 1);
2109 assert_eq!(record.itl_evidence.usage_output_tokens, Some(3));
2110 }
2111
2112 #[test]
2113 fn matching_sse_events_usage_and_intervals_are_itl_eligible() {
2114 let mut state = StreamState::new(Instant::now(), 7);
2115 for content in ["a", "b", "c"] {
2116 state
2117 .handle_payload(&format!(
2118 r#"{{"choices":[{{"delta":{{"content":"{content}"}}}}]}}"#
2119 ))
2120 .unwrap();
2121 }
2122 state
2123 .handle_payload(r#"{"choices":[],"usage":{"completion_tokens":3}}"#)
2124 .unwrap();
2125 state.done_count = 1;
2126 let record = state.finish();
2127 assert!(record.success);
2128 assert_eq!(record.itl_ms.len(), 2);
2129 assert_eq!(record.itl_evidence.eligibility, ItlEligibility::Eligible);
2130 }
2131
2132 #[tokio::test]
2133 async fn measured_join_error_becomes_failed_evidence() {
2134 let good = tokio::spawn(async {
2135 RequestRecord {
2136 success: true,
2137 ttft_ms: 1.0,
2138 e2e_ms: 2.0,
2139 input_tokens: 7,
2140 output_tokens: 1,
2141 output_token_count_source: OutputTokenCountSource::Usage,
2142 itl_evidence: RequestItlEvidence::sse(true, 1, Some(1), 0, 0),
2143 quality_issues: QualityIssueCounts::default(),
2144 itl_ms: vec![],
2145 }
2146 });
2147 let panicked = tokio::spawn(async {
2148 if true {
2149 panic!("measured task panic");
2150 }
2151 join_failed_record(0)
2152 });
2153 let records = collect_measured_handles(vec![(7, good), (11, panicked)]).await;
2154 assert_eq!(records.len(), 2);
2155 assert!(records[0].success);
2156 assert!(!records[1].success);
2157 assert_eq!(records[1].input_tokens, 11);
2158 assert_eq!(records[1].quality_issues.panic, 1);
2159 assert_eq!(
2160 records[1].output_token_count_source,
2161 OutputTokenCountSource::None
2162 );
2163 }
2164
2165 #[test]
2166 fn stream_reasoning_chunk_counts_as_token_event() {
2167 let mut state = StreamState::new(Instant::now(), 7);
2168 state
2169 .handle_payload(r#"{"choices":[{"delta":{"reasoning":"thinking"}}]}"#)
2170 .unwrap();
2171 state.done_count = 1;
2172 let record = state.finish();
2173 assert!(record.success);
2174 assert_eq!(record.output_tokens, 1);
2175 assert_eq!(
2176 record.output_token_count_source,
2177 OutputTokenCountSource::StreamChunks
2178 );
2179 }
2180
2181 #[test]
2182 fn stream_error_after_chunk_fails() {
2183 let mut state = StreamState::new(Instant::now(), 7);
2184 state
2185 .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}]}"#)
2186 .unwrap();
2187 state.stream_error = Some("broken stream".into());
2188 state.done_count = 1;
2189 let record = state.finish();
2190 assert!(!record.success);
2191 assert_eq!(
2192 record.output_token_count_source,
2193 OutputTokenCountSource::StreamChunks
2194 );
2195 }
2196
2197 #[test]
2198 fn openai_error_sse_event_fails_even_when_done_follows() {
2199 let record = parse_sse_chunks([concat!(
2200 "data: {\"error\":{\"message\":\"decode failed\",",
2201 "\"type\":\"internal_server_error\",\"param\":null,\"code\":null}}\n\n",
2202 "data: [DONE]\n\n"
2203 )
2204 .as_bytes()]);
2205
2206 assert!(!record.success);
2207 assert_eq!(record.quality_issues.malformed_stream, 1);
2208 assert_eq!(
2209 record.itl_evidence.eligibility,
2210 ItlEligibility::RequestFailed
2211 );
2212 }
2213
2214 #[test]
2215 fn eof_before_done_after_chunk_fails() {
2216 let mut state = StreamState::new(Instant::now(), 7);
2217 state
2218 .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}]}"#)
2219 .unwrap();
2220 let record = state.finish();
2221 assert!(!record.success);
2222 assert_eq!(record.output_tokens, 1);
2223 assert_eq!(record.quality_issues.missing_done, 1);
2224 }
2225
2226 #[test]
2227 fn malformed_sse_json_fails() {
2228 let mut state = StreamState::new(Instant::now(), 7);
2229 assert!(state.handle_payload("{bad json}").is_err());
2230 state.stream_error = Some("malformed json".into());
2231 state.quality_issues.malformed_stream = 1;
2232 state.done_count = 1;
2233 let record = state.finish();
2234 assert!(!record.success);
2235 assert_eq!(record.output_tokens, 0);
2236 assert_eq!(record.quality_issues.malformed_stream, 1);
2237 }
2238
2239 #[test]
2240 fn done_with_zero_content_tokens_fails() {
2241 let mut state = StreamState::new(Instant::now(), 7);
2242 state.done_count = 1;
2243 let record = state.finish();
2244 assert!(!record.success);
2245 assert_eq!(record.output_tokens, 0);
2246 assert_eq!(record.quality_issues.zero_output_tokens, 1);
2247 assert_eq!(
2248 record.output_token_count_source,
2249 OutputTokenCountSource::None
2250 );
2251 }
2252
2253 #[test]
2254 fn duplicate_done_fails() {
2255 let mut state = StreamState::new(Instant::now(), 7);
2256 state
2257 .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}]}"#)
2258 .unwrap();
2259 state.done_count = 2;
2260 let record = state.finish();
2261 assert!(!record.success);
2262 assert_eq!(record.quality_issues.duplicate_done, 1);
2263 }
2264
2265 #[test]
2266 fn bad_output_text_fails() {
2267 let mut state = StreamState::new(Instant::now(), 7);
2268 state
2269 .handle_payload(r#"{"choices":[{"delta":{"content":"<unk>"}}]}"#)
2270 .unwrap();
2271 state.done_count = 1;
2272 let record = state.finish();
2273 assert!(!record.success);
2274 assert_eq!(record.quality_issues.bad_output, 1);
2275 }
2276
2277 #[test]
2278 fn mojibake_sequences_fail_but_standalone_leads_do_not() {
2279 assert!(has_bad_output_text("caf\u{00c3}\u{00a9}"));
2280 assert!(has_bad_output_text("copyright \u{00c2}\u{00a9}"));
2281 assert!(has_bad_output_text("quote\u{00e2}\u{20ac}\u{2122}"));
2282 assert!(!has_bad_output_text("\u{00c2}"));
2283 assert!(!has_bad_output_text("\u{00c3}"));
2284 assert!(!has_bad_output_text("Grade \u{00c2} report"));
2285 }
2286
2287 #[test]
2288 fn random_prompt_generation_targets_reencoded_length_when_fixture_is_set() {
2289 let Some(path) = ferrum_env_value("FERRUM_BENCH_TOKENIZER_FIXTURE") else {
2290 return;
2291 };
2292 let tok = tokenizers::Tokenizer::from_file(path).expect("load tokenizer fixture");
2293 let mut rng = StdRng::seed_from_u64(9271);
2294 for _ in 0..16 {
2295 let text = gen_random_prompt(&tok, 256, &mut rng);
2296 assert_eq!(token_count(&tok, &text), Some(256));
2297 assert!(generated_prompt_is_safe(&text));
2298 }
2299
2300 let prompts = gen_shared_prefix_prompts(&tok, 4, 128, 16, &mut rng)
2301 .expect("generate shared-prefix prompts");
2302 assert_eq!(prompts.len(), 4);
2303 assert!(prompts
2304 .iter()
2305 .all(|prompt| generated_prompt_is_safe(&prompt.text)));
2306 }
2307
2308 #[test]
2309 fn generated_prompt_safety_rejects_transport_poisoning_text() {
2310 assert!(generated_prompt_is_safe("hello world\nnext"));
2311 assert!(!generated_prompt_is_safe("bad \u{fffd} text"));
2312 assert!(!generated_prompt_is_safe("<|im_start|>"));
2313 assert!(!generated_prompt_is_safe("control \u{7}"));
2314 }
2315
2316 fn ferrum_env_value(key: &str) -> Option<String> {
2317 ferrum_types::RuntimeConfigSnapshot::capture_current()
2318 .entries
2319 .into_iter()
2320 .find(|entry| entry.key == key)
2321 .map(|entry| entry.effective_value)
2322 }
2323
2324 fn test_command() -> BenchServeCommand {
2325 BenchServeCommand {
2326 base_url: "http://127.0.0.1:9".to_string(),
2327 model: "test-model".to_string(),
2328 tokenizer: std::path::PathBuf::from("."),
2329 target_backend: None,
2330 http_connection_mode: BenchHttpConnectionMode::Pooled,
2331 concurrency: 1,
2332 concurrency_sweep: vec![],
2333 request_rate: None,
2334 dataset: "random".to_string(),
2335 random_input_len: 2,
2336 random_output_len: 3,
2337 ignore_eos: false,
2338 enable_thinking: None,
2339 sharegpt_path: None,
2340 shared_prefix_len: 1024,
2341 shared_suffix_len: 64,
2342 num_prompts: 1,
2343 warmup_requests: 0,
2344 n_repeats: 1,
2345 goodput: None,
2346 timeout: 1.0,
2347 fail_on_error: false,
2348 max_error_rate: None,
2349 require_ci: false,
2350 seed: Some(9271),
2351 output: "json".to_string(),
2352 out: None,
2353 hw_id: None,
2354 commit_sha: None,
2355 tag: None,
2356 }
2357 }
2358
2359 fn policy_report(
2360 completed: u32,
2361 errored: u32,
2362 warmup_completed: u32,
2363 warmup_errored: u32,
2364 ) -> BenchReport {
2365 let mut records = Vec::with_capacity((completed + errored) as usize);
2366 for _ in 0..completed {
2367 records.push(RequestRecord {
2368 success: true,
2369 ttft_ms: 10.0,
2370 e2e_ms: 30.0,
2371 input_tokens: 2,
2372 output_tokens: 3,
2373 output_token_count_source: OutputTokenCountSource::Usage,
2374 itl_evidence: RequestItlEvidence::sse(true, 3, Some(3), 2, 0),
2375 quality_issues: QualityIssueCounts::default(),
2376 itl_ms: vec![10.0, 10.0],
2377 });
2378 }
2379 for _ in 0..errored {
2380 let mut quality = QualityIssueCounts::default();
2381 quality.missing_done = 1;
2382 records.push(RequestRecord {
2383 success: false,
2384 ttft_ms: 0.0,
2385 e2e_ms: 30.0,
2386 input_tokens: 2,
2387 output_tokens: 0,
2388 output_token_count_source: OutputTokenCountSource::None,
2389 itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
2390 quality_issues: quality,
2391 itl_ms: vec![],
2392 });
2393 }
2394 let warmup_expected = warmup_completed.checked_add(warmup_errored).unwrap();
2395 let mut warmup_quality = QualityIssueCounts::default();
2396 warmup_quality.missing_done = warmup_errored;
2397 compute_metrics(
2398 "test-model".to_string(),
2399 "test-backend".to_string(),
2400 Scenario::ClosedLoop,
2401 Some(1),
2402 None,
2403 2,
2404 3,
2405 warmup_expected,
2406 Slo::default(),
2407 vec![RunRecord {
2408 expected_requests: completed.checked_add(errored).unwrap(),
2409 records,
2410 duration_s: 1.0,
2411 warmup: WarmupSummary {
2412 expected: warmup_expected,
2413 completed: warmup_completed,
2414 errored: warmup_errored,
2415 quality_issues: warmup_quality,
2416 },
2417 }],
2418 Env::default(),
2419 )
2420 }
2421
2422 #[test]
2423 fn validate_command_rejects_zero_repeats_and_invalid_programmatic_slo() {
2424 let mut cmd = test_command();
2425 cmd.n_repeats = 0;
2426 assert!(validate_command(&cmd).is_err());
2427 cmd.n_repeats = 1;
2428 cmd.goodput = Some(Slo {
2429 ttft_p99_ms: f64::NAN,
2430 ..Slo::default()
2431 });
2432 assert!(validate_command(&cmd).is_err());
2433 cmd.goodput = None;
2434 cmd.timeout = f64::MAX;
2435 assert!(validate_command(&cmd).is_err());
2436 }
2437
2438 #[test]
2439 fn measured_error_rate_excludes_successful_warmups() {
2440 let report = policy_report(99, 1, 10, 0);
2441 let mut cmd = test_command();
2442 cmd.max_error_rate = Some(0.0095);
2443 let err = enforce_error_policy(&cmd, &[report]).expect_err("measured rate is one percent");
2444 assert!(err.to_string().contains("bench-serve error rate"));
2445 }
2446
2447 #[test]
2448 fn warmup_failure_is_independent_of_measured_error_allowance() {
2449 let report = policy_report(100, 0, 9, 1);
2450 let mut cmd = test_command();
2451 cmd.max_error_rate = Some(1.0);
2452 let err = enforce_error_policy(&cmd, &[report]).expect_err("warmup must be perfect");
2453 assert!(err.to_string().contains("bench-serve warmup failed"));
2454 }
2455
2456 #[test]
2457 fn fail_on_error_still_writes_json_report() {
2458 let out = std::env::temp_dir().join(format!(
2459 "ferrum-bench-serve-failed-report-{}-{}.json",
2460 std::process::id(),
2461 std::time::SystemTime::now()
2462 .duration_since(std::time::UNIX_EPOCH)
2463 .unwrap()
2464 .as_nanos()
2465 ));
2466 let _ = std::fs::remove_file(&out);
2467
2468 let mut failed_quality = QualityIssueCounts::default();
2469 failed_quality.http_500 = 1;
2470 let run = RunRecord {
2471 records: vec![
2472 RequestRecord {
2473 success: true,
2474 ttft_ms: 10.0,
2475 e2e_ms: 30.0,
2476 input_tokens: 4,
2477 output_tokens: 3,
2478 output_token_count_source: OutputTokenCountSource::Usage,
2479 itl_evidence: RequestItlEvidence::sse(true, 3, Some(3), 2, 0),
2480 quality_issues: QualityIssueCounts::default(),
2481 itl_ms: vec![10.0, 10.0],
2482 },
2483 RequestRecord {
2484 success: false,
2485 ttft_ms: 0.0,
2486 e2e_ms: 50.0,
2487 input_tokens: 4,
2488 output_tokens: 0,
2489 output_token_count_source: OutputTokenCountSource::None,
2490 itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
2491 quality_issues: failed_quality,
2492 itl_ms: vec![],
2493 },
2494 ],
2495 expected_requests: 2,
2496 duration_s: 1.0,
2497 warmup: Default::default(),
2498 };
2499 let report = compute_metrics(
2500 "test-model".to_string(),
2501 "test-backend".to_string(),
2502 Scenario::ClosedLoop,
2503 Some(2),
2504 None,
2505 2,
2506 3,
2507 0,
2508 Slo::default(),
2509 vec![run],
2510 Env::default(),
2511 );
2512 let cmd = BenchServeCommand {
2513 base_url: "http://127.0.0.1:9".to_string(),
2514 model: "test-model".to_string(),
2515 tokenizer: std::path::PathBuf::from("."),
2516 target_backend: None,
2517 http_connection_mode: BenchHttpConnectionMode::Pooled,
2518 concurrency: 2,
2519 concurrency_sweep: vec![],
2520 request_rate: None,
2521 dataset: "random".to_string(),
2522 random_input_len: 4,
2523 random_output_len: 3,
2524 ignore_eos: false,
2525 enable_thinking: None,
2526 sharegpt_path: None,
2527 shared_prefix_len: 1024,
2528 shared_suffix_len: 64,
2529 num_prompts: 2,
2530 warmup_requests: 0,
2531 n_repeats: 1,
2532 goodput: None,
2533 timeout: 1.0,
2534 fail_on_error: true,
2535 max_error_rate: None,
2536 require_ci: false,
2537 seed: Some(9271),
2538 output: "json".to_string(),
2539 out: Some(out.clone()),
2540 hw_id: None,
2541 commit_sha: None,
2542 tag: None,
2543 };
2544
2545 let err = emit_then_enforce_error_policy(&cmd, &[report]).expect_err("error policy");
2546 assert!(
2547 err.to_string().contains("bench-serve error rate"),
2548 "unexpected error: {err}"
2549 );
2550 let raw = std::fs::read_to_string(&out).expect("report written before error");
2551 let json: serde_json::Value = serde_json::from_str(&raw).expect("json report");
2552 assert_eq!(json["completed_per_run"], serde_json::json!([1]));
2553 assert_eq!(json["errored_per_run"], serde_json::json!([1]));
2554 assert_eq!(json["http_500_per_run"], serde_json::json!([1]));
2555 assert_eq!(
2556 json["output_tokens_per_request"],
2557 serde_json::json!([[3, 0]])
2558 );
2559 let _ = std::fs::remove_file(out);
2560 }
2561
2562 #[test]
2563 fn fail_on_error_rejects_warmup_only_failure() {
2564 let mut warmup_quality = QualityIssueCounts::default();
2565 warmup_quality.missing_done = 1;
2566 let report = compute_metrics(
2567 "test-model".to_string(),
2568 "test-backend".to_string(),
2569 Scenario::ClosedLoop,
2570 Some(1),
2571 None,
2572 2,
2573 3,
2574 1,
2575 Slo::default(),
2576 vec![RunRecord {
2577 records: vec![RequestRecord {
2578 success: true,
2579 ttft_ms: 10.0,
2580 e2e_ms: 30.0,
2581 input_tokens: 2,
2582 output_tokens: 3,
2583 output_token_count_source: OutputTokenCountSource::Usage,
2584 itl_evidence: RequestItlEvidence::sse(true, 3, Some(3), 2, 0),
2585 quality_issues: QualityIssueCounts::default(),
2586 itl_ms: vec![10.0, 10.0],
2587 }],
2588 expected_requests: 1,
2589 duration_s: 1.0,
2590 warmup: WarmupSummary {
2591 expected: 1,
2592 completed: 0,
2593 errored: 1,
2594 quality_issues: warmup_quality,
2595 },
2596 }],
2597 Env::default(),
2598 );
2599 let cmd = BenchServeCommand {
2600 base_url: "http://127.0.0.1:9".to_string(),
2601 model: "test-model".to_string(),
2602 tokenizer: std::path::PathBuf::from("."),
2603 target_backend: None,
2604 http_connection_mode: BenchHttpConnectionMode::Pooled,
2605 concurrency: 1,
2606 concurrency_sweep: vec![],
2607 request_rate: None,
2608 dataset: "random".to_string(),
2609 random_input_len: 2,
2610 random_output_len: 3,
2611 ignore_eos: false,
2612 enable_thinking: None,
2613 sharegpt_path: None,
2614 shared_prefix_len: 1024,
2615 shared_suffix_len: 64,
2616 num_prompts: 1,
2617 warmup_requests: 1,
2618 n_repeats: 1,
2619 goodput: None,
2620 timeout: 1.0,
2621 fail_on_error: true,
2622 max_error_rate: None,
2623 require_ci: false,
2624 seed: Some(9271),
2625 output: "json".to_string(),
2626 out: None,
2627 hw_id: None,
2628 commit_sha: None,
2629 tag: None,
2630 };
2631
2632 let err = enforce_error_policy(&cmd, &[report]).expect_err("warmup failure");
2633 assert!(err.to_string().contains("bench-serve warmup failed"));
2634 }
2635}