1use clap::{Args, ValueEnum};
20use colored::*;
21use ferrum_bench_core::env::HttpRequestSampling;
22use ferrum_bench_core::{
23 arrivals::poisson_arrival_times, compute_metrics, BenchReport, BenchmarkPhase,
24 BenchmarkRequestCorrelation, Env, ItlEvidenceSource, OutputTokenCountSource,
25 QualityIssueCounts, RequestItlEvidence, RequestRecord, RunRecord, Scenario, Slo,
26 TokenLengthStats, WarmupSummary, BENCHMARK_CELL_ID_HEADER, BENCHMARK_PHASE_HEADER,
27 BENCHMARK_REPEAT_INDEX_HEADER, BENCHMARK_REQUEST_INDEX_HEADER, BENCHMARK_RUN_ID_HEADER,
28};
29use ferrum_server::chat_template::ReasoningEffort;
30use ferrum_types::Result;
31use rand::{rngs::StdRng, Rng, SeedableRng};
32use serde::Deserialize;
33use sha2::{Digest, Sha256};
34use std::path::PathBuf;
35use std::sync::Arc;
36use std::time::{Duration, Instant};
37use tokio::sync::Semaphore;
38use tokio_stream::StreamExt;
39use uuid::Uuid;
40
41use crate::config::CliConfig;
42
43mod decode_isolation;
44mod sampling;
45
46use decode_isolation::{BenchServeWorkload, DecodeIsolationArgs};
47use sampling::BenchSamplingArgs;
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
50pub enum BenchTargetBackend {
51 Cpu,
52 Metal,
53 Cuda,
54}
55
56impl BenchTargetBackend {
57 fn as_str(self) -> &'static str {
58 match self {
59 Self::Cpu => "cpu",
60 Self::Metal => "metal",
61 Self::Cuda => "cuda",
62 }
63 }
64}
65
66#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
67pub enum BenchHttpConnectionMode {
68 #[default]
69 Pooled,
70 Fresh,
71}
72
73impl BenchHttpConnectionMode {
74 fn as_str(self) -> &'static str {
75 match self {
76 Self::Pooled => "pooled",
77 Self::Fresh => "fresh",
78 }
79 }
80}
81
82#[derive(Args, Clone)]
83pub struct BenchServeCommand {
84 #[arg(long)]
86 pub base_url: String,
87
88 #[arg(long)]
91 pub model: String,
92
93 #[arg(long)]
96 pub tokenizer: PathBuf,
97
98 #[arg(long, value_enum)]
103 pub target_backend: Option<BenchTargetBackend>,
104
105 #[arg(long, value_enum, default_value = "pooled")]
107 pub http_connection_mode: BenchHttpConnectionMode,
108
109 #[arg(long, value_enum, default_value = "standard")]
112 pub scenario: BenchServeWorkload,
113
114 #[command(flatten)]
115 pub decode_isolation: DecodeIsolationArgs,
116
117 #[command(flatten)]
118 pub sampling: BenchSamplingArgs,
119
120 #[arg(long, default_value_t = 32, alias = "max-concurrency")]
126 pub concurrency: u32,
127
128 #[arg(long, value_delimiter = ',')]
131 pub concurrency_sweep: Vec<u32>,
132
133 #[arg(long)]
136 pub request_rate: Option<f64>,
137
138 #[arg(long, default_value = "random")]
143 pub dataset: String,
144
145 #[arg(long, default_value_t = 256)]
147 pub random_input_len: usize,
148
149 #[arg(long, default_value_t = 128)]
151 pub random_output_len: usize,
152
153 #[arg(long)]
157 pub ignore_eos: bool,
158
159 #[arg(long, action = clap::ArgAction::Set)]
161 pub enable_thinking: Option<bool>,
162
163 #[arg(long, value_name = "LEVEL")]
165 pub reasoning_effort: Option<ReasoningEffort>,
166
167 #[arg(long)]
172 pub sharegpt_path: Option<PathBuf>,
173
174 #[arg(long, default_value_t = 1024)]
177 pub shared_prefix_len: usize,
178
179 #[arg(long, default_value_t = 64)]
182 pub shared_suffix_len: usize,
183
184 #[arg(long, default_value_t = 100)]
188 pub num_prompts: u32,
189
190 #[arg(long, default_value_t = 10)]
194 pub warmup_requests: u32,
195
196 #[arg(long, default_value_t = 1)]
199 pub n_repeats: u32,
200
201 #[arg(long, value_parser = parse_slo)]
205 pub goodput: Option<Slo>,
206
207 #[arg(long, default_value_t = 600.0)]
209 pub timeout: f64,
210
211 #[arg(long)]
214 pub fail_on_error: bool,
215
216 #[arg(long)]
218 pub max_error_rate: Option<f64>,
219
220 #[arg(long)]
222 pub require_ci: bool,
223
224 #[arg(long)]
226 pub seed: Option<u64>,
227
228 #[arg(long, default_value = "json")]
234 pub output: String,
235
236 #[arg(long, alias = "result-file")]
240 pub out: Option<PathBuf>,
241
242 #[arg(long)]
245 pub hw_id: Option<String>,
246
247 #[arg(long)]
249 pub commit_sha: Option<String>,
250
251 #[arg(long)]
253 pub tag: Option<String>,
254}
255
256pub(super) fn parse_slo(s: &str) -> std::result::Result<Slo, String> {
261 let mut ttft: Option<f64> = None;
262 let mut tpot: Option<f64> = None;
263 let mut e2el: Option<f64> = None;
264 for tok in s.split(|c: char| c == ',' || c.is_whitespace()) {
265 if tok.is_empty() {
266 continue;
267 }
268 let (k, v) = tok
269 .split_once(':')
270 .ok_or_else(|| format!("bad SLO token '{tok}', expected key:value"))?;
271 let v: f64 = v.parse().map_err(|e| format!("bad SLO value '{v}': {e}"))?;
272 if !v.is_finite() || v <= 0.0 {
273 return Err(format!(
274 "bad SLO value '{v}': expected a positive finite number"
275 ));
276 }
277 match k {
278 "ttft" => ttft = Some(v),
279 "tpot" => tpot = Some(v),
280 "e2el" | "e2e" => e2el = Some(v),
281 other => return Err(format!("unknown SLO key '{other}'")),
282 }
283 }
284 Ok(Slo {
285 ttft_p99_ms: ttft.ok_or("missing ttft in --goodput")?,
286 tpot_p99_ms: tpot.ok_or("missing tpot in --goodput")?,
287 e2e_p99_ms: e2el.ok_or("missing e2el in --goodput")?,
288 })
289}
290
291#[derive(Debug, Deserialize)]
296struct OpenAiStreamChunk {
297 id: Option<String>,
298 choices: Option<Vec<OpenAiStreamChoice>>,
299 usage: Option<OpenAiUsage>,
300 error: Option<OpenAiStreamError>,
301}
302
303#[derive(Debug, Deserialize)]
304struct OpenAiStreamError {
305 message: String,
306 #[serde(rename = "type")]
307 error_type: Option<String>,
308 code: Option<serde_json::Value>,
309}
310
311#[derive(Debug, Deserialize)]
312struct OpenAiStreamChoice {
313 delta: Option<OpenAiStreamDelta>,
314}
315
316#[derive(Debug, Deserialize)]
317struct OpenAiStreamDelta {
318 content: Option<String>,
319 reasoning: Option<String>,
320 reasoning_content: Option<String>,
321}
322
323#[derive(Debug, Deserialize)]
324struct OpenAiUsage {
325 prompt_tokens: Option<u32>,
326 completion_tokens: Option<u32>,
327}
328
329#[derive(Clone)]
330struct PromptCase {
331 text: String,
332 input_tokens: u32,
333 sha256: String,
334}
335
336async fn stream_one(
341 client: &reqwest::Client,
342 base_url: &str,
343 model: &str,
344 prompt: PromptCase,
345 max_tokens: usize,
346 ignore_eos: bool,
347 enable_thinking: Option<bool>,
348 reasoning_effort: Option<ReasoningEffort>,
349 sampling: HttpRequestSampling,
350 timeout_s: f64,
351 correlation: BenchmarkRequestCorrelation,
352) -> RequestRecord {
353 stream_one_observed(
354 client,
355 base_url,
356 model,
357 prompt,
358 max_tokens,
359 ignore_eos,
360 enable_thinking,
361 reasoning_effort,
362 sampling,
363 timeout_s,
364 correlation,
365 None,
366 )
367 .await
368 .record
369}
370
371#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
372pub(super) struct DecodeStreamProgress {
373 pub output_events: u32,
374 pub finished: bool,
375 pub first_output_at: Option<Instant>,
376 pub last_output_at: Option<Instant>,
377}
378
379pub(super) struct ObservedRequest {
380 pub record: RequestRecord,
381 pub started_at: Instant,
382 pub output_event_times: Vec<Instant>,
383}
384
385async fn stream_one_observed(
386 client: &reqwest::Client,
387 base_url: &str,
388 model: &str,
389 prompt: PromptCase,
390 max_tokens: usize,
391 ignore_eos: bool,
392 enable_thinking: Option<bool>,
393 reasoning_effort: Option<ReasoningEffort>,
394 sampling: HttpRequestSampling,
395 timeout_s: f64,
396 correlation: BenchmarkRequestCorrelation,
397 progress: Option<tokio::sync::watch::Sender<DecodeStreamProgress>>,
398) -> ObservedRequest {
399 let PromptCase {
400 text,
401 input_tokens,
402 sha256: prompt_sha256,
403 } = prompt;
404 let body = chat_completion_body(
405 model,
406 &text,
407 max_tokens,
408 ignore_eos,
409 enable_thinking,
410 reasoning_effort,
411 sampling,
412 );
413 let start = Instant::now();
414 let mut state = StreamState::for_prompt(
415 start,
416 input_tokens,
417 prompt_sha256.clone(),
418 Some(correlation.clone()),
419 );
420
421 let resp = match client
422 .post(format!("{}/v1/chat/completions", base_url))
423 .header(BENCHMARK_RUN_ID_HEADER, &correlation.benchmark_run_id)
424 .header(BENCHMARK_CELL_ID_HEADER, &correlation.cell_id)
425 .header(
426 BENCHMARK_REPEAT_INDEX_HEADER,
427 correlation.repeat_index.to_string(),
428 )
429 .header(BENCHMARK_PHASE_HEADER, correlation.phase.as_str())
430 .header(
431 BENCHMARK_REQUEST_INDEX_HEADER,
432 correlation.request_index.to_string(),
433 )
434 .json(&body)
435 .timeout(Duration::from_secs_f64(timeout_s))
436 .send()
437 .await
438 {
439 Ok(r) => r,
440 Err(e) => {
441 eprintln!(
442 "[err] post prompt_sha256={prompt_sha256} input_tokens={} \
443 timeout={} connect={} request={} body={} decode={}: display={e}; debug={e:?}",
444 input_tokens,
445 e.is_timeout(),
446 e.is_connect(),
447 e.is_request(),
448 e.is_body(),
449 e.is_decode(),
450 );
451 let mut quality_issues = QualityIssueCounts::default();
452 quality_issues.malformed_stream = 1;
453 publish_stream_finished(&progress, 0);
454 return ObservedRequest {
455 record: failed_record(input_tokens, start, quality_issues, correlation),
456 started_at: start,
457 output_event_times: vec![],
458 };
459 }
460 };
461 if !resp.status().is_success() {
462 let status = resp.status();
463 let txt = resp.text().await.unwrap_or_default();
464 eprintln!(
465 "[err] http prompt_sha256={prompt_sha256} {status}: {}",
466 clipped_debug_text(&txt, 200)
467 );
468 let mut quality_issues = QualityIssueCounts::default();
469 if status.as_u16() == 500 {
470 quality_issues.http_500 = 1;
471 }
472 if looks_like_panic(&txt) {
473 quality_issues.panic = 1;
474 }
475 publish_stream_finished(&progress, 0);
476 return ObservedRequest {
477 record: failed_record(input_tokens, start, quality_issues, correlation),
478 started_at: start,
479 output_event_times: vec![],
480 };
481 }
482
483 let mut stream = resp.bytes_stream();
484 let mut sse = SseLineBuffer::default();
485 while let Some(chunk) = stream.next().await {
486 let chunk = match chunk {
487 Ok(c) => c,
488 Err(e) => {
489 eprintln!("[err] stream prompt_sha256={prompt_sha256}: display={e}; debug={e:?}");
490 state.stream_error = Some(e.to_string());
491 state.quality_issues.malformed_stream = 1;
492 break;
493 }
494 };
495 let before_output_events = state.output_delta_events;
496 sse.push(&chunk, &mut state);
497 if let Some(progress) = &progress {
498 progress.send_replace(DecodeStreamProgress {
499 output_events: state.output_delta_events,
500 finished: false,
501 first_output_at: state.output_event_times.first().copied(),
502 last_output_at: state.output_event_times.last().copied(),
503 });
504 }
505 state.note_transport_chunk(
506 state
507 .output_delta_events
508 .saturating_sub(before_output_events),
509 );
510 }
511 sse.finish(&mut state);
512 let output_events = state.output_delta_events;
513 let observed = state.finish_observed();
514 if let Some(progress) = &progress {
515 progress.send_replace(DecodeStreamProgress {
516 output_events,
517 finished: true,
518 first_output_at: observed.output_event_times.first().copied(),
519 last_output_at: observed.output_event_times.last().copied(),
520 });
521 }
522 observed
523}
524
525fn publish_stream_finished(
526 progress: &Option<tokio::sync::watch::Sender<DecodeStreamProgress>>,
527 output_events: u32,
528) {
529 if let Some(progress) = progress {
530 progress.send_replace(DecodeStreamProgress {
531 output_events,
532 finished: true,
533 first_output_at: None,
534 last_output_at: None,
535 });
536 }
537}
538
539#[derive(Default)]
540struct SseLineBuffer {
541 pending: Vec<u8>,
542}
543
544impl SseLineBuffer {
545 fn push(&mut self, chunk: &[u8], state: &mut StreamState) {
546 self.pending.extend_from_slice(chunk);
547 let mut consumed = 0;
548 while let Some(relative_newline) = self.pending[consumed..]
549 .iter()
550 .position(|byte| *byte == b'\n')
551 {
552 let newline = consumed + relative_newline;
553 let line = self.pending[consumed..newline].to_vec();
554 consumed = newline + 1;
555 Self::process_line(&line, state);
556 }
557 if consumed > 0 {
558 self.pending.drain(..consumed);
559 }
560 }
561
562 fn finish(&mut self, state: &mut StreamState) {
563 if !self.pending.is_empty() {
564 let line = std::mem::take(&mut self.pending);
565 Self::process_line(&line, state);
566 }
567 }
568
569 fn process_line(raw_line: &[u8], state: &mut StreamState) {
570 let raw_line = raw_line.strip_suffix(b"\r").unwrap_or(raw_line);
571 let line = match std::str::from_utf8(raw_line) {
572 Ok(line) => line.trim(),
573 Err(error) => {
574 let message = format!("invalid UTF-8 in SSE line: {error}");
575 eprintln!("[err] prompt_sha256={} {message}", state.prompt_sha256);
576 state.stream_error = Some(message);
577 state.quality_issues.malformed_stream = 1;
578 state.quality_issues.bad_output = 1;
579 return;
580 }
581 };
582 let Some(payload) = line.strip_prefix("data:").map(str::trim) else {
583 return;
584 };
585 if payload == "[DONE]" {
586 state.done_count = state
587 .done_count
588 .checked_add(1)
589 .expect("SSE done count overflow");
590 if state.done_count > 1 {
591 state.quality_issues.duplicate_done = 1;
592 }
593 return;
594 }
595 if let Err(error) = state.handle_payload(payload) {
596 eprintln!(
597 "[err] malformed sse json prompt_sha256={}: {error}",
598 state.prompt_sha256
599 );
600 state.stream_error = Some(error);
601 state.quality_issues.malformed_stream = 1;
602 }
603 }
604}
605
606fn chat_completion_body(
607 model: &str,
608 prompt_text: &str,
609 max_tokens: usize,
610 ignore_eos: bool,
611 enable_thinking: Option<bool>,
612 reasoning_effort: Option<ReasoningEffort>,
613 sampling: HttpRequestSampling,
614) -> serde_json::Value {
615 let mut body = serde_json::json!({
616 "model": model,
617 "messages": [{"role": "user", "content": prompt_text}],
618 "max_tokens": max_tokens,
619 "stream": true,
620 "stream_options": {"include_usage": true},
621 });
622 let serde_json::Value::Object(sampling) =
623 serde_json::to_value(sampling).expect("validated HTTP sampling must serialize")
624 else {
625 unreachable!("HTTP sampling serializes as an object");
626 };
627 body.as_object_mut()
628 .expect("request body is an object")
629 .extend(sampling);
630 let mut chat_template_kwargs = serde_json::Map::new();
631 if let Some(enable_thinking) = enable_thinking {
632 chat_template_kwargs.insert(
633 "enable_thinking".to_string(),
634 serde_json::json!(enable_thinking),
635 );
636 }
637 if let Some(reasoning_effort) = reasoning_effort {
638 chat_template_kwargs.insert(
639 "reasoning_effort".to_string(),
640 serde_json::json!(reasoning_effort),
641 );
642 }
643 if !chat_template_kwargs.is_empty() {
644 body["chat_template_kwargs"] = serde_json::Value::Object(chat_template_kwargs);
645 }
646 if ignore_eos {
647 body["ignore_eos"] = serde_json::json!(true);
648 }
649 body
650}
651
652fn failed_record(
653 input_tokens: u32,
654 start: Instant,
655 quality_issues: QualityIssueCounts,
656 benchmark_correlation: BenchmarkRequestCorrelation,
657) -> RequestRecord {
658 RequestRecord {
659 benchmark_correlation: Some(benchmark_correlation),
660 server_request_id: None,
661 success: false,
662 ttft_ms: 0.0,
663 e2e_ms: start.elapsed().as_secs_f64() * 1000.0,
664 input_tokens,
665 server_input_tokens: None,
666 output_tokens: 0,
667 output_token_count_source: OutputTokenCountSource::None,
668 itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
669 quality_issues,
670 itl_ms: vec![],
671 }
672}
673
674fn join_failed_record(
675 input_tokens: u32,
676 benchmark_correlation: BenchmarkRequestCorrelation,
677) -> RequestRecord {
678 let mut quality_issues = QualityIssueCounts::default();
679 quality_issues.panic = 1;
680 RequestRecord {
681 benchmark_correlation: Some(benchmark_correlation),
682 server_request_id: None,
683 success: false,
684 ttft_ms: 0.0,
685 e2e_ms: 0.0,
686 input_tokens,
687 server_input_tokens: None,
688 output_tokens: 0,
689 output_token_count_source: OutputTokenCountSource::None,
690 itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
691 quality_issues,
692 itl_ms: vec![],
693 }
694}
695
696async fn collect_measured_handles(
697 handles: Vec<(
698 u32,
699 BenchmarkRequestCorrelation,
700 tokio::task::JoinHandle<RequestRecord>,
701 )>,
702) -> Vec<RequestRecord> {
703 let mut records = Vec::with_capacity(handles.len());
704 for (input_tokens, correlation, handle) in handles {
705 match handle.await {
706 Ok(record) => records.push(record),
707 Err(error) => {
708 eprintln!("[err] measured request task: {error}");
709 records.push(join_failed_record(input_tokens, correlation));
710 }
711 }
712 }
713 records
714}
715
716struct StreamState {
717 start: Instant,
718 input_tokens: u32,
719 usage_prompt_tokens: Option<u32>,
720 first_token_time: Option<Instant>,
721 last_token_time: Option<Instant>,
722 output_delta_events: u32,
723 usage_completion_tokens: Option<u32>,
724 itl_ms: Vec<f64>,
725 transport_coalesced_output_chunks: u32,
726 done_count: u32,
727 stream_error: Option<String>,
728 quality_issues: QualityIssueCounts,
729 prompt_sha256: String,
730 benchmark_correlation: Option<BenchmarkRequestCorrelation>,
731 server_request_id: Option<String>,
732 output_event_times: Vec<Instant>,
733}
734
735impl StreamState {
736 #[cfg(test)]
737 fn new(start: Instant, input_tokens: u32) -> Self {
738 Self::for_prompt(start, input_tokens, "unknown".to_string(), None)
739 }
740
741 fn for_prompt(
742 start: Instant,
743 input_tokens: u32,
744 prompt_sha256: String,
745 benchmark_correlation: Option<BenchmarkRequestCorrelation>,
746 ) -> Self {
747 Self {
748 start,
749 input_tokens,
750 usage_prompt_tokens: None,
751 first_token_time: None,
752 last_token_time: None,
753 output_delta_events: 0,
754 usage_completion_tokens: None,
755 itl_ms: Vec::new(),
756 transport_coalesced_output_chunks: 0,
757 done_count: 0,
758 stream_error: None,
759 quality_issues: QualityIssueCounts::default(),
760 prompt_sha256,
761 benchmark_correlation,
762 server_request_id: None,
763 output_event_times: Vec::new(),
764 }
765 }
766
767 fn handle_payload(&mut self, payload: &str) -> std::result::Result<(), String> {
768 let chunk: OpenAiStreamChunk =
769 serde_json::from_str(payload).map_err(|e| format!("{e}: {payload}"))?;
770 if let Some(id) = chunk.id.filter(|id| !id.is_empty()) {
771 if self
772 .server_request_id
773 .as_ref()
774 .is_some_and(|observed| observed != &id)
775 {
776 return Err(format!(
777 "OpenAI stream response changed request id from {} to {id}",
778 self.server_request_id.as_deref().unwrap_or_default()
779 ));
780 }
781 self.server_request_id = Some(id);
782 }
783 if let Some(error) = chunk.error {
784 return Err(format!(
785 "OpenAI stream error type={} code={} message={}",
786 error.error_type.as_deref().unwrap_or("unknown"),
787 error
788 .code
789 .as_ref()
790 .map(serde_json::Value::to_string)
791 .unwrap_or_else(|| "null".to_owned()),
792 error.message
793 ));
794 }
795 if let Some(usage) = chunk.usage {
796 if let Some(tokens) = usage.prompt_tokens {
797 self.usage_prompt_tokens = Some(tokens);
798 }
799 if let Some(tokens) = usage.completion_tokens {
800 self.usage_completion_tokens = Some(tokens);
801 }
802 }
803 if let Some(choices) = chunk.choices {
804 if let Some(first) = choices.into_iter().next() {
805 if let Some(delta) = first.delta {
806 if let Some(text) = first_non_empty_delta_text(&delta) {
807 let now = Instant::now();
808 if self.first_token_time.is_none() {
809 self.first_token_time = Some(now);
810 } else if let Some(prev) = self.last_token_time {
811 self.itl_ms.push((now - prev).as_secs_f64() * 1000.0);
812 }
813 self.last_token_time = Some(now);
814 self.output_event_times.push(now);
815 self.output_delta_events = self
816 .output_delta_events
817 .checked_add(1)
818 .expect("stream output event count overflow");
819 if let Some(reason) = bad_output_reason(text) {
820 eprintln!(
821 "[err] bad output prompt_sha256={} {reason}: {}",
822 self.prompt_sha256,
823 clipped_debug_text(text, 160)
824 );
825 self.quality_issues.bad_output = 1;
826 }
827 if looks_like_panic(text) {
828 self.quality_issues.panic = 1;
829 }
830 }
831 }
832 }
833 }
834 Ok(())
835 }
836
837 fn note_transport_chunk(&mut self, output_events: u32) {
838 if output_events > 1 {
839 self.transport_coalesced_output_chunks = self
840 .transport_coalesced_output_chunks
841 .checked_add(1)
842 .expect("coalesced output transport chunk count overflow");
843 }
844 }
845
846 fn finish(mut self) -> RequestRecord {
847 let (output_tokens, source) = match self.usage_completion_tokens {
848 Some(tokens) => (tokens, OutputTokenCountSource::Usage),
849 None if self.output_delta_events > 0 => (
850 self.output_delta_events,
851 OutputTokenCountSource::StreamChunks,
852 ),
853 None => (0, OutputTokenCountSource::None),
854 };
855 if self.done_count == 0 {
856 self.quality_issues.missing_done = 1;
857 } else if self.done_count > 1 {
858 self.quality_issues.duplicate_done = 1;
859 }
860 if output_tokens == 0 {
861 self.quality_issues.zero_output_tokens = 1;
862 }
863 let e2e_ms = self.start.elapsed().as_secs_f64() * 1000.0;
864 let ttft_ms = self
865 .first_token_time
866 .map(|t| t.duration_since(self.start).as_secs_f64() * 1000.0)
867 .unwrap_or(e2e_ms);
868 let success = self.done_count == 1
869 && output_tokens > 0
870 && self.stream_error.is_none()
871 && self.quality_issues.request_error_count() == 0;
872 let observed_intervals =
873 u32::try_from(self.itl_ms.len()).expect("stream ITL interval count overflow");
874 let itl_evidence = RequestItlEvidence::sse(
875 success,
876 self.output_delta_events,
877 self.usage_completion_tokens,
878 observed_intervals,
879 self.transport_coalesced_output_chunks,
880 );
881 RequestRecord {
882 benchmark_correlation: self.benchmark_correlation,
883 server_request_id: self.server_request_id,
884 success,
885 ttft_ms,
886 e2e_ms,
887 input_tokens: self.input_tokens,
888 server_input_tokens: self.usage_prompt_tokens,
889 output_tokens,
890 output_token_count_source: source,
891 itl_evidence,
892 quality_issues: self.quality_issues,
893 itl_ms: self.itl_ms,
894 }
895 }
896
897 fn finish_observed(mut self) -> ObservedRequest {
898 let started_at = self.start;
899 let output_event_times = std::mem::take(&mut self.output_event_times);
900 let record = self.finish();
901 ObservedRequest {
902 record,
903 started_at,
904 output_event_times,
905 }
906 }
907}
908
909fn first_non_empty_delta_text(delta: &OpenAiStreamDelta) -> Option<&str> {
910 delta
911 .content
912 .as_deref()
913 .filter(|s| !s.is_empty())
914 .or_else(|| delta.reasoning.as_deref().filter(|s| !s.is_empty()))
915 .or_else(|| delta.reasoning_content.as_deref().filter(|s| !s.is_empty()))
916}
917
918#[cfg(test)]
919fn has_bad_output_text(text: &str) -> bool {
920 bad_output_reason(text).is_some()
921}
922
923fn bad_output_reason(text: &str) -> Option<&'static str> {
924 const BAD_FRAGMENTS: &[(&str, &str)] = &[
925 ("<unk>", "reserved-token"),
926 ("[PAD", "reserved-token"),
927 ("<pad>", "reserved-token"),
928 ("<|endoftext|>", "reserved-token"),
929 ("<|im_start|>", "reserved-token"),
930 ("<|im_end|>", "reserved-token"),
931 ("<|reserved_special_token", "reserved-token"),
932 ("\u{fffd}", "invalid-utf8"),
933 ];
934 for (fragment, reason) in BAD_FRAGMENTS {
935 if text.contains(fragment) {
936 return Some(reason);
937 }
938 }
939 contains_mojibake_sequence(text).then_some("mojibake")
940}
941
942fn contains_mojibake_sequence(text: &str) -> bool {
943 let mut chars = text.chars().peekable();
944 while let Some(ch) = chars.next() {
945 match ch {
946 '\u{00c2}' | '\u{00c3}' => {
951 if chars.peek().is_some_and(|next| !next.is_ascii()) {
952 return true;
953 }
954 }
955 '\u{00e2}' => {
958 if chars.peek().is_some_and(|next| *next == '\u{20ac}') {
959 return true;
960 }
961 }
962 _ => {}
963 }
964 }
965 false
966}
967
968fn clipped_debug_text(text: &str, max_chars: usize) -> String {
969 text.escape_debug().take(max_chars).collect()
970}
971
972fn sha256_hex(bytes: &[u8]) -> String {
973 let mut hasher = Sha256::new();
974 hasher.update(bytes);
975 format!("{:x}", hasher.finalize())
976}
977
978fn looks_like_panic(text: &str) -> bool {
979 let lower = text.to_ascii_lowercase();
980 lower.contains("panicked at") || lower.contains("thread '") && lower.contains("panicked")
981}
982
983fn gen_random_prompt(
989 tok: &tokenizers::Tokenizer,
990 n_tokens: usize,
991 rng: &mut (impl Rng + ?Sized),
992) -> String {
993 if let Some(text) = gen_random_prompt_target_len(tok, n_tokens, rng) {
994 return text;
995 }
996 " x".repeat(n_tokens)
1000}
1001
1002fn generated_prompt_is_safe(text: &str) -> bool {
1003 !text.is_empty()
1004 && !text.contains("<|")
1005 && bad_output_reason(text).is_none()
1006 && text
1007 .chars()
1008 .all(|ch| !ch.is_control() || matches!(ch, '\n' | '\r' | '\t'))
1009}
1010
1011fn sample_safe_token_ids(
1012 tok: &tokenizers::Tokenizer,
1013 count: usize,
1014 rng: &mut (impl Rng + ?Sized),
1015 lo: u32,
1016 hi: u32,
1017) -> Option<Vec<u32>> {
1018 let mut ids = Vec::with_capacity(count);
1019 for _ in 0..count {
1020 let mut selected = None;
1021 for _ in 0..256 {
1022 let id = rng.random_range(lo..=hi);
1023 let Ok(piece) = tok.decode(&[id], false) else {
1024 continue;
1025 };
1026 if generated_prompt_is_safe(&piece) {
1027 selected = Some(id);
1028 break;
1029 }
1030 }
1031 ids.push(selected?);
1032 }
1033 Some(ids)
1034}
1035
1036fn gen_random_prompt_target_len(
1037 tok: &tokenizers::Tokenizer,
1038 target_len: usize,
1039 rng: &mut (impl Rng + ?Sized),
1040) -> Option<String> {
1041 if target_len == 0 {
1042 return Some(String::new());
1043 }
1044 let vocab_size = tok.get_vocab_size(false) as u32;
1045 let lo: u32 = 256.min(vocab_size.saturating_sub(1));
1046 let hi: u32 = vocab_size.saturating_sub(1);
1047 if hi < lo {
1048 return None;
1049 }
1050
1051 let mut sample_len = target_len;
1052 let mut best_under: Option<(usize, String)> = None;
1053 let mut best_any: Option<(usize, String)> = None;
1054 for _ in 0..64 {
1055 let ids = sample_safe_token_ids(tok, sample_len, rng, lo, hi)?;
1056 let text = match tok.decode(&ids, false) {
1057 Ok(text) if generated_prompt_is_safe(&text) => text,
1058 _ => continue,
1059 };
1060 let len = match token_count(tok, &text) {
1061 Some(len) => len,
1062 None => continue,
1063 };
1064 if len == target_len {
1065 return Some(text);
1066 }
1067 let delta = len.abs_diff(target_len);
1068 if best_any
1069 .as_ref()
1070 .map(|(best_len, _)| delta < best_len.abs_diff(target_len))
1071 .unwrap_or(true)
1072 {
1073 best_any = Some((len, text.clone()));
1074 }
1075 if len < target_len
1076 && best_under
1077 .as_ref()
1078 .map(|(best_len, _)| len > *best_len)
1079 .unwrap_or(true)
1080 {
1081 best_under = Some((len, text));
1082 }
1083
1084 sample_len = if len > target_len {
1087 sample_len.saturating_sub(len - target_len).max(1)
1088 } else {
1089 sample_len + (target_len - len).max(1)
1090 };
1091 }
1092
1093 if let Some((len, text)) = best_under {
1094 return fill_random_prompt_to_len(tok, text, len, target_len, rng, lo, hi);
1095 }
1096 best_any.map(|(_, text)| text)
1097}
1098
1099fn token_count(tok: &tokenizers::Tokenizer, text: &str) -> Option<usize> {
1100 tok.encode(text, false).ok().map(|enc| enc.len())
1101}
1102
1103fn fill_random_prompt_to_len(
1104 tok: &tokenizers::Tokenizer,
1105 mut text: String,
1106 mut len: usize,
1107 target_len: usize,
1108 rng: &mut (impl Rng + ?Sized),
1109 lo: u32,
1110 hi: u32,
1111) -> Option<String> {
1112 for _ in 0..target_len.saturating_mul(8).max(16) {
1113 if len == target_len {
1114 return Some(text);
1115 }
1116 let next = random_one_token_extension(tok, &text, len, rng, lo, hi)?;
1117 text = next.0;
1118 len = next.1;
1119 }
1120 (len == target_len).then_some(text)
1121}
1122
1123fn random_one_token_extension(
1124 tok: &tokenizers::Tokenizer,
1125 base: &str,
1126 base_len: usize,
1127 rng: &mut (impl Rng + ?Sized),
1128 lo: u32,
1129 hi: u32,
1130) -> Option<(String, usize)> {
1131 for _ in 0..128 {
1133 let id = rng.random_range(lo..=hi);
1134 if let Some(candidate) = append_decoded_piece(tok, base, base_len, id) {
1135 return Some(candidate);
1136 }
1137 }
1138 for piece in [" x", " y", " z", ".", ",", "\n"] {
1141 let candidate = format!("{base}{piece}");
1142 if token_count(tok, &candidate) == Some(base_len + 1) {
1143 return Some((candidate, base_len + 1));
1144 }
1145 }
1146 None
1147}
1148
1149fn append_decoded_piece(
1150 tok: &tokenizers::Tokenizer,
1151 base: &str,
1152 base_len: usize,
1153 id: u32,
1154) -> Option<(String, usize)> {
1155 let piece = tok.decode(&[id], false).ok()?;
1156 if !generated_prompt_is_safe(&piece) {
1157 return None;
1158 }
1159 let candidate = format!("{base}{piece}");
1160 (generated_prompt_is_safe(&candidate) && token_count(tok, &candidate) == Some(base_len + 1))
1161 .then_some((candidate, base_len + 1))
1162}
1163
1164fn build_prompts(
1165 cmd: &BenchServeCommand,
1166 tok: &tokenizers::Tokenizer,
1167 rng: &mut (impl Rng + ?Sized),
1168 count: usize,
1169) -> Result<Vec<PromptCase>> {
1170 match cmd.dataset.as_str() {
1171 "random" => (0..count)
1172 .map(|_| {
1173 let text = gen_random_prompt(tok, cmd.random_input_len, rng);
1174 prompt_case(tok, text)
1175 })
1176 .collect(),
1177 "shared-prefix" => gen_shared_prefix_prompts(
1178 tok,
1179 count,
1180 cmd.shared_prefix_len,
1181 cmd.shared_suffix_len,
1182 rng,
1183 ),
1184 "sharegpt" => {
1185 let p = cmd.sharegpt_path.as_ref().ok_or_else(|| {
1186 ferrum_types::FerrumError::model("--dataset sharegpt requires --sharegpt-path PATH")
1187 })?;
1188 load_sharegpt_prompts(p, tok, count, rng)
1189 }
1190 other => Err(ferrum_types::FerrumError::model(format!(
1191 "unknown --dataset '{}': allowed values are random, sharegpt, shared-prefix",
1192 other
1193 ))),
1194 }
1195}
1196
1197fn prompt_case(tok: &tokenizers::Tokenizer, text: String) -> Result<PromptCase> {
1198 let encoding = tok
1199 .encode(text.as_str(), false)
1200 .map_err(|e| ferrum_types::FerrumError::model(format!("tokenize generated prompt: {e}")))?;
1201 let input_tokens = u32::try_from(encoding.len()).map_err(|_| {
1202 ferrum_types::FerrumError::model("generated prompt token count exceeds report capacity")
1203 })?;
1204 let sha256 = sha256_hex(text.as_bytes());
1205 Ok(PromptCase {
1206 text,
1207 input_tokens,
1208 sha256,
1209 })
1210}
1211
1212fn gen_shared_prefix_prompts(
1222 tok: &tokenizers::Tokenizer,
1223 count: usize,
1224 prefix_len: usize,
1225 suffix_len: usize,
1226 rng: &mut (impl Rng + ?Sized),
1227) -> Result<Vec<PromptCase>> {
1228 let prefix = gen_random_prompt(tok, prefix_len, rng);
1231 (0..count)
1232 .map(|_| {
1233 let suffix = gen_random_prompt(tok, suffix_len, rng);
1234 prompt_case(tok, format!("{prefix}\n{suffix}"))
1238 })
1239 .collect()
1240}
1241
1242fn load_sharegpt_prompts(
1253 path: &std::path::Path,
1254 tok: &tokenizers::Tokenizer,
1255 count: usize,
1256 rng: &mut (impl Rng + ?Sized),
1257) -> Result<Vec<PromptCase>> {
1258 use std::io::BufRead;
1259 let f = std::fs::File::open(path).map_err(|e| {
1260 ferrum_types::FerrumError::model(format!("open sharegpt {}: {e}", path.display()))
1261 })?;
1262 let mut prompts: Vec<String> = Vec::new();
1263 for (idx, line) in std::io::BufReader::new(f).lines().enumerate() {
1264 let line = line.map_err(|e| {
1265 ferrum_types::FerrumError::model(format!("read line {idx} of {}: {e}", path.display()))
1266 })?;
1267 if line.trim().is_empty() {
1268 continue;
1269 }
1270 let v: serde_json::Value = match serde_json::from_str(&line) {
1271 Ok(v) => v,
1272 Err(e) => {
1273 eprintln!("[warn] sharegpt line {idx}: skip (parse error: {e})");
1274 continue;
1275 }
1276 };
1277 let prompt: Option<String> = v
1279 .get("conversations")
1280 .and_then(|c| c.as_array())
1281 .and_then(|arr| {
1282 arr.iter()
1283 .find(|t| t.get("from").and_then(|f| f.as_str()) == Some("human"))
1284 .and_then(|t| {
1285 t.get("value")
1286 .and_then(|x| x.as_str())
1287 .map(|s| s.to_string())
1288 })
1289 })
1290 .or_else(|| {
1292 v.get("input")
1293 .and_then(|s| s.as_str())
1294 .map(|s| s.to_string())
1295 });
1296 if let Some(p) = prompt {
1297 if !p.is_empty() {
1298 prompts.push(p);
1299 }
1300 }
1301 }
1302 if prompts.is_empty() {
1303 return Err(ferrum_types::FerrumError::model(format!(
1304 "sharegpt {}: no usable prompts found",
1305 path.display()
1306 )));
1307 }
1308 let mut out = Vec::with_capacity(count);
1311 for _ in 0..count {
1312 let idx = rng.random_range(0..prompts.len());
1313 out.push(prompt_case(tok, prompts[idx].clone())?);
1314 }
1315 Ok(out)
1316}
1317
1318struct RunContext {
1323 client: Arc<reqwest::Client>,
1324 base_url: Arc<String>,
1325 model: Arc<String>,
1326 max_out: usize,
1327 ignore_eos: bool,
1328 enable_thinking: Option<bool>,
1329 reasoning_effort: Option<ReasoningEffort>,
1330 sampling: HttpRequestSampling,
1331 timeout_s: f64,
1332 benchmark_run_id: Arc<String>,
1333}
1334
1335fn benchmark_request_correlation(
1336 benchmark_run_id: &str,
1337 cell_id: &str,
1338 repeat_index: u32,
1339 phase: BenchmarkPhase,
1340 request_index: usize,
1341) -> BenchmarkRequestCorrelation {
1342 BenchmarkRequestCorrelation::new(
1343 benchmark_run_id.to_string(),
1344 cell_id.to_string(),
1345 repeat_index,
1346 phase,
1347 u32::try_from(request_index).expect("benchmark request index overflow"),
1348 )
1349 .expect("generated benchmark correlation must be valid")
1350}
1351
1352fn summarize_warmup(
1353 expected: usize,
1354 records: &[RequestRecord],
1355 join_errors: usize,
1356) -> WarmupSummary {
1357 assert_eq!(records.len() + join_errors, expected);
1358 let completed = records.iter().filter(|record| record.success).count() as u32;
1359 let mut quality_issues = QualityIssueCounts::default();
1360 for record in records {
1361 quality_issues.add_assign(&record.quality_issues);
1362 }
1363 quality_issues.panic = quality_issues
1364 .panic
1365 .checked_add(u32::try_from(join_errors).expect("warmup join error count overflow"))
1366 .expect("warmup panic count overflow");
1367 let expected = u32::try_from(expected).expect("warmup expected count overflow");
1368 WarmupSummary {
1369 expected,
1370 completed,
1371 errored: expected
1372 .checked_sub(completed)
1373 .expect("warmup completed count exceeds expected"),
1374 quality_issues,
1375 }
1376}
1377
1378async fn run_closed_loop(
1382 ctx: &RunContext,
1383 prompts: Vec<PromptCase>,
1384 warmup_requests: u32,
1385 concurrency: u32,
1386 cell_id: &str,
1387 repeat_index: u32,
1388) -> RunRecord {
1389 let n_warmup = warmup_requests as usize;
1390 let total = prompts.len();
1391 assert!(
1392 total > n_warmup,
1393 "num_prompts ({total}) must exceed warmup_requests ({n_warmup})"
1394 );
1395
1396 let warmup = {
1398 let sem = Arc::new(Semaphore::new(concurrency as usize));
1399 let mut handles = Vec::new();
1400 for (request_index, prompt) in prompts.iter().take(n_warmup).enumerate() {
1401 let permit = sem.clone().acquire_owned().await.expect("semaphore");
1402 let ctx_c = ctx.clone_inner();
1403 let p = prompt.clone();
1404 let correlation = benchmark_request_correlation(
1405 &ctx.benchmark_run_id,
1406 cell_id,
1407 repeat_index,
1408 BenchmarkPhase::Warmup,
1409 request_index,
1410 );
1411 handles.push(tokio::spawn(async move {
1412 let _g = permit;
1413 stream_one(
1414 &ctx_c.client,
1415 &ctx_c.base_url,
1416 &ctx_c.model,
1417 p,
1418 ctx_c.max_out,
1419 ctx_c.ignore_eos,
1420 ctx_c.enable_thinking,
1421 ctx_c.reasoning_effort,
1422 ctx_c.sampling,
1423 ctx_c.timeout_s,
1424 correlation,
1425 )
1426 .await
1427 }));
1428 }
1429 let mut records = Vec::with_capacity(n_warmup);
1430 let mut join_errors = 0;
1431 for handle in handles {
1432 match handle.await {
1433 Ok(record) => records.push(record),
1434 Err(_) => join_errors += 1,
1435 }
1436 }
1437 summarize_warmup(n_warmup, &records, join_errors)
1438 };
1439
1440 let sem = Arc::new(Semaphore::new(concurrency as usize));
1442 let start = Instant::now();
1443 let mut handles = Vec::with_capacity(total - n_warmup);
1444 for (request_index, prompt) in prompts.into_iter().skip(n_warmup).enumerate() {
1445 let input_tokens = prompt.input_tokens;
1446 let permit = sem.clone().acquire_owned().await.expect("semaphore");
1447 let ctx_c = ctx.clone_inner();
1448 let correlation = benchmark_request_correlation(
1449 &ctx.benchmark_run_id,
1450 cell_id,
1451 repeat_index,
1452 BenchmarkPhase::Measured,
1453 request_index,
1454 );
1455 handles.push((
1456 input_tokens,
1457 correlation.clone(),
1458 tokio::spawn(async move {
1459 let _g = permit;
1460 stream_one(
1461 &ctx_c.client,
1462 &ctx_c.base_url,
1463 &ctx_c.model,
1464 prompt,
1465 ctx_c.max_out,
1466 ctx_c.ignore_eos,
1467 ctx_c.enable_thinking,
1468 ctx_c.reasoning_effort,
1469 ctx_c.sampling,
1470 ctx_c.timeout_s,
1471 correlation,
1472 )
1473 .await
1474 }),
1475 ));
1476 }
1477 let records = collect_measured_handles(handles).await;
1478 let duration_s = start.elapsed().as_secs_f64();
1479 RunRecord {
1480 records,
1481 expected_requests: u32::try_from(total - n_warmup)
1482 .expect("measured request count overflow"),
1483 duration_s,
1484 warmup,
1485 }
1486}
1487
1488async fn run_open_loop(
1491 ctx: &RunContext,
1492 prompts: Vec<PromptCase>,
1493 warmup_requests: u32,
1494 rate: f64,
1495 cell_id: &str,
1496 repeat_index: u32,
1497) -> RunRecord {
1498 let n_warmup = warmup_requests as usize;
1499 let total = prompts.len();
1500 assert!(total > n_warmup);
1501
1502 let mut warmup_records = Vec::with_capacity(n_warmup);
1504 for (request_index, prompt) in prompts.iter().take(n_warmup).enumerate() {
1505 warmup_records.push(
1506 stream_one(
1507 &ctx.client,
1508 &ctx.base_url,
1509 &ctx.model,
1510 prompt.clone(),
1511 ctx.max_out,
1512 ctx.ignore_eos,
1513 ctx.enable_thinking,
1514 ctx.reasoning_effort,
1515 ctx.sampling,
1516 ctx.timeout_s,
1517 benchmark_request_correlation(
1518 &ctx.benchmark_run_id,
1519 cell_id,
1520 repeat_index,
1521 BenchmarkPhase::Warmup,
1522 request_index,
1523 ),
1524 )
1525 .await,
1526 );
1527 }
1528 let warmup = summarize_warmup(n_warmup, &warmup_records, 0);
1529
1530 let mut rng = rand::rng();
1532 let measurement_count = total - n_warmup;
1533 let schedule = poisson_arrival_times(rate, measurement_count, &mut rng);
1534
1535 let start = Instant::now();
1536 let mut handles = Vec::with_capacity(measurement_count);
1537 for (i, prompt) in prompts.into_iter().skip(n_warmup).enumerate() {
1538 let target = schedule[i];
1539 let now = start.elapsed().as_secs_f64();
1540 if target > now {
1541 tokio::time::sleep(Duration::from_secs_f64(target - now)).await;
1542 }
1543 let ctx_c = ctx.clone_inner();
1544 let input_tokens = prompt.input_tokens;
1545 let correlation = benchmark_request_correlation(
1546 &ctx.benchmark_run_id,
1547 cell_id,
1548 repeat_index,
1549 BenchmarkPhase::Measured,
1550 i,
1551 );
1552 handles.push((
1553 input_tokens,
1554 correlation.clone(),
1555 tokio::spawn(async move {
1556 stream_one(
1557 &ctx_c.client,
1558 &ctx_c.base_url,
1559 &ctx_c.model,
1560 prompt,
1561 ctx_c.max_out,
1562 ctx_c.ignore_eos,
1563 ctx_c.enable_thinking,
1564 ctx_c.reasoning_effort,
1565 ctx_c.sampling,
1566 ctx_c.timeout_s,
1567 correlation,
1568 )
1569 .await
1570 }),
1571 ));
1572 }
1573 let records = collect_measured_handles(handles).await;
1574 let duration_s = start.elapsed().as_secs_f64();
1575 RunRecord {
1576 records,
1577 expected_requests: u32::try_from(measurement_count)
1578 .expect("measured request count overflow"),
1579 duration_s,
1580 warmup,
1581 }
1582}
1583
1584impl RunContext {
1585 fn clone_inner(&self) -> Self {
1586 Self {
1587 client: self.client.clone(),
1588 base_url: self.base_url.clone(),
1589 model: self.model.clone(),
1590 max_out: self.max_out,
1591 ignore_eos: self.ignore_eos,
1592 enable_thinking: self.enable_thinking,
1593 reasoning_effort: self.reasoning_effort,
1594 sampling: self.sampling,
1595 timeout_s: self.timeout_s,
1596 benchmark_run_id: self.benchmark_run_id.clone(),
1597 }
1598 }
1599}
1600
1601fn build_env(cmd: &BenchServeCommand, features: Vec<String>) -> Env {
1606 let commit_sha = cmd
1607 .commit_sha
1608 .clone()
1609 .or_else(|| {
1610 std::process::Command::new("git")
1611 .args(["rev-parse", "--short", "HEAD"])
1612 .output()
1613 .ok()
1614 .and_then(|o| String::from_utf8(o.stdout).ok())
1615 .map(|s| s.trim().to_string())
1616 })
1617 .unwrap_or_else(|| "unknown".to_string());
1618
1619 let mut env = Env::capture_minimal(commit_sha, features);
1620 env.http_connection_mode = Some(cmd.http_connection_mode.as_str().to_string());
1621 env.http_request_sampling = Some(cmd.sampling.request_sampling());
1622 if let Some(hw) = cmd.hw_id.clone() {
1623 env.hw_id = hw;
1624 }
1625 env
1626}
1627
1628fn detect_features() -> Vec<String> {
1629 let mut v = Vec::new();
1630 #[cfg(feature = "metal")]
1631 v.push("metal".to_string());
1632 #[cfg(feature = "cuda")]
1633 v.push("cuda".to_string());
1634 #[cfg(feature = "vllm-marlin")]
1635 v.push("vllm-marlin".to_string());
1636 #[cfg(feature = "vllm-moe-marlin")]
1637 v.push("vllm-moe-marlin".to_string());
1638 #[cfg(feature = "vllm-paged-attn-v2")]
1639 v.push("vllm-paged-attn-v2".to_string());
1640 #[cfg(feature = "triton-kernels")]
1641 v.push("triton-kernels".to_string());
1642 v.sort();
1643 v.dedup();
1644 v
1645}
1646
1647#[derive(Clone, Copy)]
1652enum Cell {
1653 Closed(u32),
1654 Open(f64),
1655}
1656
1657async fn execute_cell(
1658 cmd: &BenchServeCommand,
1659 ctx: &RunContext,
1660 cell: Cell,
1661 cell_id: &str,
1662) -> Result<BenchReport> {
1663 let backend = cmd
1667 .target_backend
1668 .map(BenchTargetBackend::as_str)
1669 .unwrap_or_else(|| {
1670 if cfg!(feature = "cuda") {
1671 "cuda"
1672 } else if cfg!(feature = "metal") {
1673 "metal"
1674 } else {
1675 "cpu"
1676 }
1677 });
1678
1679 let tokenizer_path = cmd.tokenizer.join("tokenizer.json");
1680 let tok = tokenizers::Tokenizer::from_file(&tokenizer_path).map_err(|e| {
1681 ferrum_types::FerrumError::model(format!(
1682 "Load tokenizer at {}: {}",
1683 tokenizer_path.display(),
1684 e
1685 ))
1686 })?;
1687 let total_prompts_u32 = cmd
1688 .num_prompts
1689 .checked_add(cmd.warmup_requests)
1690 .ok_or_else(|| {
1691 ferrum_types::FerrumError::model("num_prompts + warmup_requests overflow")
1692 })?;
1693 let total_prompts = usize::try_from(total_prompts_u32)
1694 .map_err(|_| ferrum_types::FerrumError::model("prompt count exceeds platform capacity"))?;
1695
1696 let mut runs: Vec<RunRecord> = Vec::with_capacity(cmd.n_repeats as usize);
1697 let mut actual_input_lengths: Vec<u32> = Vec::new();
1698 let mut actual_input_tokens_per_request: Vec<Vec<u32>> =
1699 Vec::with_capacity(cmd.n_repeats as usize);
1700 for repeat_idx in 0..cmd.n_repeats {
1701 let mut seeded_rng;
1702 let mut thread_rng;
1703 let rng: &mut dyn rand::RngCore = if let Some(seed) = cmd.seed {
1704 seeded_rng =
1705 StdRng::seed_from_u64(seed ^ ((repeat_idx as u64) << 32) ^ cell_seed(cell));
1706 &mut seeded_rng
1707 } else {
1708 thread_rng = rand::rng();
1709 &mut thread_rng
1710 };
1711 let prompts = build_prompts(cmd, &tok, rng, total_prompts)?;
1712 let measured_input_lengths: Vec<u32> = prompts
1713 .iter()
1714 .skip(cmd.warmup_requests as usize)
1715 .map(|p| p.input_tokens)
1716 .collect();
1717 actual_input_lengths.extend(measured_input_lengths.iter().copied());
1718 actual_input_tokens_per_request.push(measured_input_lengths);
1719 eprintln!(
1720 "{}",
1721 format!(
1722 " cell {} — repeat {}/{} ({} prompts including {} warmup)",
1723 cell_label(cell),
1724 repeat_idx + 1,
1725 cmd.n_repeats,
1726 total_prompts,
1727 cmd.warmup_requests
1728 )
1729 .dimmed()
1730 );
1731 let run = match cell {
1732 Cell::Closed(c) => {
1733 run_closed_loop(ctx, prompts, cmd.warmup_requests, c, cell_id, repeat_idx).await
1734 }
1735 Cell::Open(r) => {
1736 run_open_loop(ctx, prompts, cmd.warmup_requests, r, cell_id, repeat_idx).await
1737 }
1738 };
1739 eprintln!(
1740 " {} completed / {} errored / {:.1}s",
1741 run.n_completed(),
1742 run.n_errored(),
1743 run.duration_s
1744 );
1745 runs.push(run);
1746 }
1747 let requested_input_len = requested_input_len(cmd)?;
1748 let requested_output_len = u32::try_from(cmd.random_output_len).map_err(|_| {
1749 ferrum_types::FerrumError::model("random output length exceeds report capacity")
1750 })?;
1751 let actual_input_tokens = input_token_stats(&actual_input_lengths, requested_input_len);
1752 let token_count_source = output_token_count_source_from_runs(&runs);
1753
1754 let env = build_env(cmd, detect_features());
1755 let slo = cmd.goodput.unwrap_or_else(Slo::unbounded);
1756 let model_field = match &cmd.tag {
1757 Some(t) => format!("{}#{}", cmd.model, t),
1758 None => cmd.model.clone(),
1759 };
1760
1761 let (scenario, concurrency, request_rate) = match cell {
1762 Cell::Closed(c) => (Scenario::ClosedLoop, Some(c), None),
1763 Cell::Open(r) => (Scenario::OpenLoop, None, Some(r)),
1764 };
1765
1766 let mut report = compute_metrics(
1767 model_field,
1768 backend.to_string(),
1769 scenario,
1770 concurrency,
1771 request_rate,
1772 requested_input_len,
1773 requested_output_len,
1774 cmd.warmup_requests,
1775 slo,
1776 runs,
1777 env,
1778 );
1779 report.actual_input_tokens = Some(actual_input_tokens);
1780 report.actual_input_tokens_per_request = Some(actual_input_tokens_per_request);
1781 report.output_token_count_source = Some(token_count_source);
1782 Ok(report)
1783}
1784
1785fn requested_input_len(cmd: &BenchServeCommand) -> Result<u32> {
1786 match cmd.dataset.as_str() {
1787 "shared-prefix" => cmd
1788 .shared_prefix_len
1789 .checked_add(cmd.shared_suffix_len)
1790 .and_then(|value| u32::try_from(value).ok())
1791 .ok_or_else(|| {
1792 ferrum_types::FerrumError::model(
1793 "shared prefix + suffix length exceeds report capacity",
1794 )
1795 }),
1796 _ => u32::try_from(cmd.random_input_len).map_err(|_| {
1797 ferrum_types::FerrumError::model("random input length exceeds report capacity")
1798 }),
1799 }
1800}
1801
1802fn input_token_stats(lengths: &[u32], requested: u32) -> TokenLengthStats {
1803 let min = lengths.iter().copied().min().unwrap_or(0);
1804 let max = lengths.iter().copied().max().unwrap_or(0);
1805 let mean = if lengths.is_empty() {
1806 0.0
1807 } else {
1808 lengths.iter().map(|&n| n as f64).sum::<f64>() / lengths.len() as f64
1809 };
1810 TokenLengthStats {
1811 requested,
1812 min,
1813 max,
1814 mean,
1815 }
1816}
1817
1818fn output_token_count_source_from_runs(runs: &[RunRecord]) -> String {
1819 let mut saw_usage = false;
1820 let mut saw_stream_chunks = false;
1821 let mut saw_none = false;
1822 for record in runs.iter().flat_map(|run| &run.records) {
1823 match record.output_token_count_source {
1824 OutputTokenCountSource::Usage => saw_usage = true,
1825 OutputTokenCountSource::StreamChunks => saw_stream_chunks = true,
1826 OutputTokenCountSource::None => saw_none = true,
1827 }
1828 }
1829 match (saw_usage, saw_stream_chunks, saw_none) {
1830 (true, false, false) => "usage".to_string(),
1831 (false, true, false) => "stream_chunks".to_string(),
1832 (false, false, true) => "none".to_string(),
1833 _ => "mixed".to_string(),
1834 }
1835}
1836
1837fn cell_seed(cell: Cell) -> u64 {
1838 match cell {
1839 Cell::Closed(c) => 0xC10C_ED00_0000_0000u64 ^ c as u64,
1840 Cell::Open(r) => 0x0FEE_D000_0000_0000u64 ^ r.to_bits(),
1841 }
1842}
1843
1844fn cell_label(cell: Cell) -> String {
1845 match cell {
1846 Cell::Closed(c) => format!("closed_loop c={c}"),
1847 Cell::Open(r) => format!("open_loop rate={r}"),
1848 }
1849}
1850
1851fn benchmark_cell_id(index: usize, cell: Cell) -> String {
1852 match cell {
1853 Cell::Closed(concurrency) => format!("cell-{index}-closed-c{concurrency}"),
1854 Cell::Open(rate) => format!("cell-{index}-open-rate-bits-{:016x}", rate.to_bits()),
1855 }
1856}
1857
1858pub async fn execute(cmd: BenchServeCommand, _cfg: CliConfig) -> Result<()> {
1863 validate_command(&cmd)?;
1864 let banner = if cmd.scenario == BenchServeWorkload::DecodeIsolation {
1865 format!(
1866 "ferrum bench-serve — scenario=decode-isolation dataset=random warmup={} n_repeats={} fixed_output=true",
1867 cmd.warmup_requests, cmd.n_repeats
1868 )
1869 } else {
1870 format!(
1871 "ferrum bench-serve — dataset={} num_prompts={} warmup={} n_repeats={}",
1872 cmd.dataset, cmd.num_prompts, cmd.warmup_requests, cmd.n_repeats
1873 )
1874 };
1875 eprintln!("{}", banner.dimmed());
1876 if cmd.n_repeats < 3 {
1877 eprintln!(
1878 "{}",
1879 " [warn] n_repeats < 3 — emitting mean only, no stddev/CI95 (PLAYBOOK § 0.4)".yellow()
1880 );
1881 }
1882
1883 let cells: Vec<Cell> = if let Some(rate) = cmd.request_rate {
1885 vec![Cell::Open(rate)]
1886 } else if !cmd.concurrency_sweep.is_empty() {
1887 cmd.concurrency_sweep
1888 .iter()
1889 .copied()
1890 .map(Cell::Closed)
1891 .collect()
1892 } else {
1893 vec![Cell::Closed(cmd.concurrency)]
1894 };
1895
1896 let pool_max_idle_per_host = match cmd.http_connection_mode {
1897 BenchHttpConnectionMode::Pooled => 64,
1898 BenchHttpConnectionMode::Fresh => 0,
1899 };
1900 let client = Arc::new(
1901 reqwest::Client::builder()
1902 .pool_max_idle_per_host(pool_max_idle_per_host)
1903 .build()
1904 .map_err(|e| ferrum_types::FerrumError::model(format!("reqwest client: {e}")))?,
1905 );
1906 let ctx = RunContext {
1907 client,
1908 base_url: Arc::new(cmd.base_url.clone()),
1909 model: Arc::new(cmd.model.clone()),
1910 max_out: cmd.random_output_len,
1911 ignore_eos: cmd.ignore_eos,
1912 enable_thinking: cmd.enable_thinking,
1913 reasoning_effort: cmd.reasoning_effort,
1914 sampling: cmd.sampling.request_sampling(),
1915 timeout_s: cmd.timeout,
1916 benchmark_run_id: Arc::new(format!("bench-{}", Uuid::new_v4())),
1917 };
1918
1919 if cmd.scenario == BenchServeWorkload::DecodeIsolation {
1920 return decode_isolation::execute(&cmd, &ctx).await;
1921 }
1922
1923 let mut reports: Vec<BenchReport> = Vec::with_capacity(cells.len());
1924 for (cell_index, cell) in cells.into_iter().enumerate() {
1925 eprintln!("{}", format!("→ {}", cell_label(cell)).bold());
1926 let cell_id = benchmark_cell_id(cell_index, cell);
1927 let r = execute_cell(&cmd, &ctx, cell, &cell_id).await?;
1928 emit_summary_line(&r);
1929 reports.push(r);
1930 }
1931
1932 emit_then_enforce_error_policy(&cmd, &reports)
1933}
1934
1935fn emit_then_enforce_error_policy(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1936 emit_reports(cmd, reports)?;
1939
1940 ferrum_bench_core::trace::flush_global_trace();
1942
1943 enforce_error_policy(cmd, reports)
1944}
1945
1946fn emit_reports(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
1947 match cmd.output.as_str() {
1948 "json" => emit_json(cmd, reports)?,
1949 "jsonl" => emit_jsonl(cmd, reports)?,
1950 "md" => emit_markdown(cmd, reports)?,
1951 other => {
1952 return Err(ferrum_types::FerrumError::model(format!(
1953 "unknown --output '{other}': allowed values are json, jsonl, md"
1954 )))
1955 }
1956 }
1957 Ok(())
1958}
1959
1960fn validate_command(cmd: &BenchServeCommand) -> Result<()> {
1961 cmd.sampling.request_sampling().validate()?;
1962 if cmd.scenario == BenchServeWorkload::DecodeIsolation {
1963 if cmd.dataset != "random" {
1964 return Err(ferrum_types::FerrumError::model(
1965 "--scenario decode-isolation requires --dataset random",
1966 ));
1967 }
1968 if cmd.sharegpt_path.is_some()
1969 || cmd.shared_prefix_len != 1024
1970 || cmd.shared_suffix_len != 64
1971 {
1972 return Err(ferrum_types::FerrumError::model(
1973 "--scenario decode-isolation cannot use ShareGPT or shared-prefix options",
1974 ));
1975 }
1976 if cmd.num_prompts != 100 || cmd.concurrency != 32 {
1977 return Err(ferrum_types::FerrumError::model(
1978 "--scenario decode-isolation derives request count and concurrency; do not set --num-prompts or --concurrency",
1979 ));
1980 }
1981 if cmd.goodput.is_some() {
1982 return Err(ferrum_types::FerrumError::model(
1983 "--scenario decode-isolation does not produce goodput",
1984 ));
1985 }
1986 if cmd.target_backend.is_none() {
1987 return Err(ferrum_types::FerrumError::model(
1988 "--scenario decode-isolation requires --target-backend",
1989 ));
1990 }
1991 }
1992 if cmd.scenario == BenchServeWorkload::DecodeIsolation
1993 && (cmd.request_rate.is_some() || !cmd.concurrency_sweep.is_empty())
1994 {
1995 return Err(ferrum_types::FerrumError::model(
1996 "--scenario decode-isolation cannot be combined with --request-rate or --concurrency-sweep",
1997 ));
1998 }
1999 if let Some(rate) = cmd.request_rate {
2000 if rate <= 0.0 || !rate.is_finite() {
2001 return Err(ferrum_types::FerrumError::model(
2002 "--request-rate must be a positive finite number",
2003 ));
2004 }
2005 }
2006 if cmd.timeout <= 0.0
2007 || !cmd.timeout.is_finite()
2008 || Duration::try_from_secs_f64(cmd.timeout).is_err()
2009 {
2010 return Err(ferrum_types::FerrumError::model(
2011 "--timeout must be a positive finite duration",
2012 ));
2013 }
2014 if cmd.concurrency == 0 {
2015 return Err(ferrum_types::FerrumError::model(
2016 "--concurrency must be > 0",
2017 ));
2018 }
2019 if cmd.num_prompts == 0 {
2020 return Err(ferrum_types::FerrumError::model(
2021 "--num-prompts must be > 0",
2022 ));
2023 }
2024 if cmd.n_repeats == 0 {
2025 return Err(ferrum_types::FerrumError::model("--n-repeats must be > 0"));
2026 }
2027 if cmd.random_input_len == 0 {
2028 return Err(ferrum_types::FerrumError::model(
2029 "--random-input-len must be > 0",
2030 ));
2031 }
2032 if cmd.random_output_len == 0 {
2033 return Err(ferrum_types::FerrumError::model(
2034 "--random-output-len must be > 0",
2035 ));
2036 }
2037 u32::try_from(cmd.random_output_len).map_err(|_| {
2038 ferrum_types::FerrumError::model("--random-output-len exceeds report capacity")
2039 })?;
2040 requested_input_len(cmd)?;
2041 if cmd.goodput.is_some_and(|slo| !slo.is_valid()) {
2042 return Err(ferrum_types::FerrumError::model(
2043 "--goodput values must be positive finite numbers",
2044 ));
2045 }
2046 if cmd.require_ci && cmd.n_repeats < 3 {
2047 return Err(ferrum_types::FerrumError::model(
2048 "--require-ci requires --n-repeats >= 3",
2049 ));
2050 }
2051 if let Some(max_error_rate) = cmd.max_error_rate {
2052 if !(0.0..=1.0).contains(&max_error_rate) || !max_error_rate.is_finite() {
2053 return Err(ferrum_types::FerrumError::model(
2054 "--max-error-rate must be in [0.0, 1.0]",
2055 ));
2056 }
2057 }
2058 if !matches!(cmd.output.as_str(), "json" | "jsonl" | "md") {
2059 return Err(ferrum_types::FerrumError::model(format!(
2060 "unknown --output '{}': allowed values are json, jsonl, md",
2061 cmd.output
2062 )));
2063 }
2064 if cmd.output == "jsonl" && cmd.out.is_none() {
2065 return Err(ferrum_types::FerrumError::model(
2066 "--output jsonl requires --out PATH (append-mode log)",
2067 ));
2068 }
2069 for cell in &cmd.concurrency_sweep {
2070 if *cell == 0 {
2071 return Err(ferrum_types::FerrumError::model(
2072 "--concurrency-sweep values must be > 0",
2073 ));
2074 }
2075 }
2076 let _total_prompts = cmd
2077 .num_prompts
2078 .checked_add(cmd.warmup_requests)
2079 .ok_or_else(|| {
2080 ferrum_types::FerrumError::model("num_prompts + warmup_requests overflow")
2081 })?;
2082 Ok(())
2083}
2084
2085fn enforce_error_policy(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
2086 let max_error_rate = cmd.max_error_rate.unwrap_or(0.0);
2087 for report in reports {
2088 let warmup_errored: u64 = report
2089 .repeat_metrics
2090 .iter()
2091 .map(|repeat| repeat.warmup_errored as u64)
2092 .sum();
2093 let warmup_has_quality_issue = report
2094 .repeat_metrics
2095 .iter()
2096 .any(|repeat| repeat.warmup_quality_issues != QualityIssueCounts::default());
2097 if warmup_errored > 0 || warmup_has_quality_issue {
2098 return Err(ferrum_types::FerrumError::model(format!(
2099 "bench-serve warmup failed for {}: {} errored request(s)",
2100 report.model, warmup_errored
2101 )));
2102 }
2103 if !cmd.fail_on_error && cmd.max_error_rate.is_none() {
2104 continue;
2105 }
2106
2107 let completed: u64 = report
2108 .completed_per_run
2109 .iter()
2110 .map(|value| *value as u64)
2111 .sum();
2112 let errored: u64 = report
2113 .errored_per_run
2114 .iter()
2115 .map(|value| *value as u64)
2116 .sum();
2117 let total = completed
2118 .checked_add(errored)
2119 .ok_or_else(|| ferrum_types::FerrumError::model("measured request count overflow"))?;
2120 let error_rate = if total == 0 {
2121 1.0
2122 } else {
2123 errored as f64 / total as f64
2124 };
2125 if error_rate > max_error_rate {
2126 return Err(ferrum_types::FerrumError::model(format!(
2127 "bench-serve error rate {:.4} exceeds max {:.4} for {}",
2128 error_rate, max_error_rate, report.model
2129 )));
2130 }
2131 }
2132 Ok(())
2133}
2134
2135fn emit_jsonl(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
2136 use std::io::Write as _;
2137 let out_path = cmd.out.as_ref().ok_or_else(|| {
2138 ferrum_types::FerrumError::model("--output jsonl requires --out PATH (append-mode log)")
2139 })?;
2140 let mut f = std::fs::OpenOptions::new()
2141 .create(true)
2142 .append(true)
2143 .open(out_path)
2144 .map_err(|e| {
2145 ferrum_types::FerrumError::model(format!("open {}: {e}", out_path.display()))
2146 })?;
2147 for r in reports {
2148 let line = serde_json::to_string(r).expect("serialize");
2149 writeln!(f, "{}", line)
2150 .map_err(|e| ferrum_types::FerrumError::model(format!("write jsonl: {e}")))?;
2151 }
2152 eprintln!(
2153 "\n→ appended {} record(s) to {}",
2154 reports.len(),
2155 out_path.display()
2156 );
2157 Ok(())
2158}
2159
2160fn emit_markdown(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
2161 use ferrum_bench_core::report::{render_single, render_sweep};
2162 let md = if reports.len() == 1 {
2163 render_single(&reports[0])
2164 } else {
2165 render_sweep(reports)
2166 };
2167 if let Some(out) = cmd.out.as_ref() {
2168 std::fs::write(out, &md).map_err(|e| {
2169 ferrum_types::FerrumError::model(format!("write {}: {e}", out.display()))
2170 })?;
2171 eprintln!("\n→ wrote {}", out.display());
2172 } else {
2173 println!("{}", md);
2174 }
2175 Ok(())
2176}
2177
2178fn emit_summary_line(r: &BenchReport) {
2179 let scenario_str = match r.scenario {
2180 Scenario::ClosedLoop => format!("c={}", r.concurrency.unwrap_or(0)),
2181 Scenario::OpenLoop => format!("rate={}", r.request_rate.unwrap_or(0.0)),
2182 Scenario::SharedPrefix => "shared_prefix".to_string(),
2183 Scenario::Cli => "cli".to_string(),
2184 Scenario::DecodeIsolation => "decode_isolation".to_string(),
2185 };
2186 let ci = if r.n_repeats >= 3 {
2187 format!(" (n_repeats={}, ± = ci95_hw)", r.n_repeats)
2188 } else {
2189 format!(" (n_repeats={}, no CI)", r.n_repeats)
2190 };
2191 eprintln!(" {} {}{}", "summary".bold(), scenario_str, ci);
2192 fmt_metric("TTFT_ms ", &r.ttft_ms, r.n_repeats);
2193 fmt_metric("TPOT_ms ", &r.tpot_ms, r.n_repeats);
2194 if r.has_complete_itl_evidence() {
2195 fmt_metric("ITL_ms ", &r.itl_ms, r.n_repeats);
2196 } else {
2197 eprintln!(" ITL_ms unavailable");
2198 }
2199 let thr = &r.output_throughput_tps;
2200 let good = &r.goodput_rps;
2201 if r.n_repeats >= 3 {
2202 eprintln!(
2203 " throughput {:.1} ± {:.1} tok/s",
2204 thr.mean, thr.ci95_hw
2205 );
2206 eprintln!(
2207 " goodput {:.2} ± {:.2} req/s",
2208 good.mean, good.ci95_hw
2209 );
2210 } else {
2211 eprintln!(" throughput {:.1} tok/s", thr.mean);
2212 eprintln!(" goodput {:.2} req/s", good.mean);
2213 }
2214}
2215
2216fn fmt_metric(name: &str, m: &ferrum_bench_core::MetricSet, n_repeats: u32) {
2217 if n_repeats >= 3 {
2218 eprintln!(
2219 " {} p50={:.1}±{:.1} p95={:.1}±{:.1} p99={:.1}±{:.1}",
2220 name, m.p50.mean, m.p50.ci95_hw, m.p95.mean, m.p95.ci95_hw, m.p99.mean, m.p99.ci95_hw
2221 );
2222 } else {
2223 eprintln!(
2224 " {} p50={:.1} p95={:.1} p99={:.1}",
2225 name, m.p50.mean, m.p95.mean, m.p99.mean
2226 );
2227 }
2228}
2229
2230fn emit_json(cmd: &BenchServeCommand, reports: &[BenchReport]) -> Result<()> {
2231 let value = if reports.len() == 1 {
2232 serde_json::to_value(&reports[0]).expect("serialize")
2233 } else {
2234 serde_json::to_value(reports).expect("serialize")
2235 };
2236 let pretty = serde_json::to_string_pretty(&value).expect("pretty");
2237 if let Some(out) = cmd.out.as_ref() {
2238 std::fs::write(out, &pretty).map_err(|e| {
2239 ferrum_types::FerrumError::model(format!("write {}: {e}", out.display()))
2240 })?;
2241 eprintln!("\n→ wrote {}", out.display());
2242 } else {
2243 println!("{}", pretty);
2244 }
2245 Ok(())
2246}
2247
2248#[cfg(test)]
2249mod tests {
2250 use super::*;
2251 use ferrum_bench_core::ItlEligibility;
2252
2253 fn parse_sse_chunks<'a>(chunks: impl IntoIterator<Item = &'a [u8]>) -> RequestRecord {
2254 let mut state = StreamState::new(Instant::now(), 7);
2255 let mut parser = SseLineBuffer::default();
2256 for chunk in chunks {
2257 let before_output_events = state.output_delta_events;
2258 parser.push(chunk, &mut state);
2259 let output_events = state
2260 .output_delta_events
2261 .saturating_sub(before_output_events);
2262 state.note_transport_chunk(output_events);
2263 }
2264 parser.finish(&mut state);
2265 state.finish()
2266 }
2267
2268 #[test]
2269 fn stream_preserves_server_prompt_usage_separately_from_content_length() {
2270 for (usage, expected) in [
2271 (r#"{"prompt_tokens":23,"completion_tokens":1}"#, Some(23)),
2272 (r#"{"completion_tokens":1}"#, None),
2273 ] {
2274 let stream = format!(
2275 "data: {{\"choices\":[{{\"delta\":{{\"content\":\"OK\"}}}}]}}\n\ndata: {{\"choices\":[],\"usage\":{usage}}}\n\ndata: [DONE]\n\n"
2276 );
2277 let record = parse_sse_chunks(stream.as_bytes().chunks(3));
2278 assert!(record.success);
2279 assert_eq!(record.input_tokens, 7);
2280 assert_eq!(record.server_input_tokens, expected);
2281 assert_eq!(record.output_tokens, 1);
2282 }
2283 }
2284
2285 fn stream_semantics(
2286 record: &RequestRecord,
2287 ) -> (
2288 bool,
2289 u32,
2290 OutputTokenCountSource,
2291 QualityIssueCounts,
2292 RequestItlEvidence,
2293 ) {
2294 (
2295 record.success,
2296 record.output_tokens,
2297 record.output_token_count_source,
2298 record.quality_issues.clone(),
2299 record.itl_evidence.clone(),
2300 )
2301 }
2302
2303 #[test]
2304 fn slo_parses_space_separated() {
2305 let s = parse_slo("ttft:500 tpot:50 e2el:30000").unwrap();
2306 assert_eq!(s.ttft_p99_ms, 500.0);
2307 assert_eq!(s.tpot_p99_ms, 50.0);
2308 assert_eq!(s.e2e_p99_ms, 30000.0);
2309 }
2310
2311 #[test]
2312 fn slo_parses_comma_separated() {
2313 let s = parse_slo("ttft:500,tpot:50,e2el:30000").unwrap();
2314 assert_eq!(s.ttft_p99_ms, 500.0);
2315 }
2316
2317 #[test]
2318 fn slo_accepts_e2e_alias() {
2319 let s = parse_slo("ttft:1 tpot:2 e2e:3").unwrap();
2320 assert_eq!(s.e2e_p99_ms, 3.0);
2321 }
2322
2323 #[test]
2324 fn slo_rejects_missing_key() {
2325 assert!(parse_slo("ttft:500 tpot:50").is_err());
2326 }
2327
2328 #[test]
2329 fn slo_rejects_unknown_key() {
2330 assert!(parse_slo("ttft:500 tpot:50 e2el:30000 bogus:1").is_err());
2331 }
2332
2333 #[test]
2334 fn slo_rejects_nonfinite_and_nonpositive_values() {
2335 for value in ["NaN", "inf", "0", "-1"] {
2336 assert!(
2337 parse_slo(&format!("ttft:{value} tpot:50 e2el:30000")).is_err(),
2338 "SLO value {value} should be rejected"
2339 );
2340 }
2341 }
2342
2343 #[test]
2344 fn stream_record_preserves_openai_response_request_id() {
2345 let record = parse_sse_chunks([
2346 b"data: {\"id\":\"chatcmpl-123\",\"choices\":[{\"delta\":{\"content\":\"ok\"}}],\"usage\":null}\n\n"
2347 .as_slice(),
2348 b"data: {\"id\":\"chatcmpl-123\",\"choices\":[],\"usage\":{\"completion_tokens\":1}}\n\ndata: [DONE]\n\n"
2349 .as_slice(),
2350 ]);
2351 assert!(record.success);
2352 assert_eq!(record.server_request_id.as_deref(), Some("chatcmpl-123"));
2353 }
2354
2355 #[test]
2356 fn chat_completion_body_omits_ignore_eos_by_default() {
2357 let body = chat_completion_body(
2358 "model",
2359 "prompt",
2360 128,
2361 false,
2362 None,
2363 None,
2364 HttpRequestSampling::default(),
2365 );
2366 assert_eq!(body["model"], serde_json::json!("model"));
2367 assert_eq!(body["max_tokens"], serde_json::json!(128));
2368 assert_eq!(body["stream"], serde_json::json!(true));
2369 assert!(body.get("chat_template_kwargs").is_none());
2370 assert_eq!(
2371 body["stream_options"]["include_usage"],
2372 serde_json::json!(true)
2373 );
2374 assert!(body.get("ignore_eos").is_none());
2375 }
2376
2377 #[test]
2378 fn chat_completion_body_sends_ignore_eos_when_requested() {
2379 let body = chat_completion_body(
2380 "model",
2381 "prompt",
2382 128,
2383 true,
2384 None,
2385 None,
2386 HttpRequestSampling::default(),
2387 );
2388 assert_eq!(body["ignore_eos"], serde_json::json!(true));
2389 }
2390
2391 #[test]
2392 fn chat_completion_body_sends_typed_thinking_values() {
2393 let disabled = chat_completion_body(
2394 "model",
2395 "prompt",
2396 128,
2397 false,
2398 Some(false),
2399 None,
2400 HttpRequestSampling::default(),
2401 );
2402 assert_eq!(
2403 disabled["chat_template_kwargs"]["enable_thinking"],
2404 serde_json::json!(false)
2405 );
2406 let enabled = chat_completion_body(
2407 "model",
2408 "prompt",
2409 128,
2410 false,
2411 Some(true),
2412 None,
2413 HttpRequestSampling::default(),
2414 );
2415 assert_eq!(
2416 enabled["chat_template_kwargs"]["enable_thinking"],
2417 serde_json::json!(true)
2418 );
2419 }
2420
2421 #[test]
2422 fn chat_completion_body_sends_typed_reasoning_effort() {
2423 let body = chat_completion_body(
2424 "model",
2425 "prompt",
2426 128,
2427 false,
2428 Some(false),
2429 Some(ReasoningEffort::Low),
2430 HttpRequestSampling::default(),
2431 );
2432 assert_eq!(
2433 body["chat_template_kwargs"]["enable_thinking"],
2434 serde_json::json!(false)
2435 );
2436 assert_eq!(
2437 body["chat_template_kwargs"]["reasoning_effort"],
2438 serde_json::json!("low")
2439 );
2440 }
2441
2442 #[test]
2443 fn thinking_and_reasoning_effort_cli_are_typed() {
2444 use clap::Parser as _;
2445
2446 #[derive(clap::Parser)]
2447 struct TestCli {
2448 #[command(flatten)]
2449 command: BenchServeCommand,
2450 }
2451
2452 let base = [
2453 "test",
2454 "--base-url",
2455 "http://127.0.0.1:8000",
2456 "--model",
2457 "model",
2458 "--tokenizer",
2459 ".",
2460 ];
2461 let absent = TestCli::parse_from(base).command;
2462 assert_eq!(absent.enable_thinking, None);
2463 assert_eq!(absent.reasoning_effort, None);
2464 assert_eq!(absent.http_connection_mode, BenchHttpConnectionMode::Pooled);
2465
2466 let disabled =
2467 TestCli::parse_from(base.into_iter().chain(["--enable-thinking", "false"])).command;
2468 assert_eq!(disabled.enable_thinking, Some(false));
2469
2470 let enabled =
2471 TestCli::parse_from(base.into_iter().chain(["--enable-thinking", "true"])).command;
2472 assert_eq!(enabled.enable_thinking, Some(true));
2473
2474 let low =
2475 TestCli::parse_from(base.into_iter().chain(["--reasoning-effort", "low"])).command;
2476 assert_eq!(low.reasoning_effort, Some(ReasoningEffort::Low));
2477 let xhigh =
2478 TestCli::parse_from(base.into_iter().chain(["--reasoning-effort", "xhigh"])).command;
2479 assert_eq!(xhigh.reasoning_effort, Some(ReasoningEffort::XHigh));
2480
2481 let metal =
2482 TestCli::parse_from(base.into_iter().chain(["--target-backend", "metal"])).command;
2483 assert_eq!(metal.target_backend, Some(BenchTargetBackend::Metal));
2484
2485 let fresh =
2486 TestCli::parse_from(base.into_iter().chain(["--http-connection-mode", "fresh"]))
2487 .command;
2488 assert_eq!(fresh.http_connection_mode, BenchHttpConnectionMode::Fresh);
2489
2490 let isolation = TestCli::parse_from(base.into_iter().chain([
2491 "--scenario",
2492 "decode-isolation",
2493 "--decode-isolation-incumbents",
2494 "6",
2495 "--decode-isolation-prefill-tokens",
2496 "4096",
2497 ]))
2498 .command;
2499 assert_eq!(isolation.scenario, BenchServeWorkload::DecodeIsolation);
2500 assert_eq!(
2501 isolation.decode_isolation.decode_isolation_incumbents,
2502 Some(6)
2503 );
2504 assert_eq!(
2505 isolation.decode_isolation.decode_isolation_prefill_tokens,
2506 Some(4096)
2507 );
2508 }
2509
2510 #[test]
2511 fn bench_env_locks_http_connection_mode() {
2512 let pooled_cmd = test_command();
2513 let pooled = build_env(&pooled_cmd, vec![]);
2514 assert_eq!(pooled.http_connection_mode.as_deref(), Some("pooled"));
2515
2516 let mut fresh_cmd = pooled_cmd;
2517 fresh_cmd.http_connection_mode = BenchHttpConnectionMode::Fresh;
2518 let fresh = build_env(&fresh_cmd, vec![]);
2519 assert_eq!(fresh.http_connection_mode.as_deref(), Some("fresh"));
2520 assert_ne!(pooled.hash(), fresh.hash());
2521 }
2522
2523 #[test]
2524 fn incremental_sse_utf8_semantics_are_invariant_to_every_byte_cut() {
2525 let stream = concat!(
2526 "data: {\"choices\":[{\"delta\":{\"content\":\"你好🙂\"}}],\"usage\":null}\n\n",
2527 "data: {\"choices\":[],\"usage\":{\"completion_tokens\":1}}\n\n",
2528 "data: [DONE]\n\n",
2529 )
2530 .as_bytes();
2531 let baseline = parse_sse_chunks([stream]);
2532 assert!(baseline.success);
2533 assert_eq!(baseline.quality_issues, QualityIssueCounts::default());
2534
2535 for cut in 0..=stream.len() {
2536 let split = parse_sse_chunks([&stream[..cut], &stream[cut..]]);
2537 assert_eq!(
2538 stream_semantics(&split),
2539 stream_semantics(&baseline),
2540 "SSE semantics changed at byte cut {cut}"
2541 );
2542 }
2543
2544 let bytewise = parse_sse_chunks(stream.chunks(1));
2545 assert_eq!(stream_semantics(&bytewise), stream_semantics(&baseline));
2546 }
2547
2548 #[test]
2549 fn transport_coalescing_is_diagnostic_not_stream_bulk_failure() {
2550 const FIRST: &[u8] =
2551 b"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}],\"usage\":null}\n\n";
2552 const SECOND: &[u8] =
2553 b"data: {\"choices\":[{\"delta\":{\"content\":\"b\"}}],\"usage\":null}\n\n";
2554 const TAIL: &[u8] =
2555 b"data: {\"choices\":[],\"usage\":{\"completion_tokens\":2}}\n\ndata: [DONE]\n\n";
2556 let together_bytes = [FIRST, SECOND, TAIL].concat();
2557 let together = parse_sse_chunks([together_bytes.as_slice()]);
2558 assert!(together.success);
2559 assert_eq!(together.quality_issues.stream_bulk_flush, 0);
2560 assert_eq!(together.itl_evidence.transport_coalesced_output_chunks, 1);
2561 assert_eq!(
2562 together.itl_evidence.eligibility,
2563 ItlEligibility::TransportCoalesced
2564 );
2565
2566 let separated = parse_sse_chunks([FIRST, SECOND, TAIL]);
2567 assert!(separated.success);
2568 assert_eq!(separated.quality_issues, QualityIssueCounts::default());
2569 assert_eq!(separated.itl_evidence.transport_coalesced_output_chunks, 0);
2570 assert_eq!(separated.itl_evidence.eligibility, ItlEligibility::Eligible);
2571 }
2572
2573 #[test]
2574 fn stream_done_with_usage_succeeds() {
2575 let mut state = StreamState::new(Instant::now(), 7);
2576 state
2577 .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}],"usage":null}"#)
2578 .unwrap();
2579 state
2580 .handle_payload(r#"{"choices":[],"usage":{"completion_tokens":3}}"#)
2581 .unwrap();
2582 state.done_count = 1;
2583 let record = state.finish();
2584 assert!(record.success);
2585 assert_eq!(record.output_tokens, 3);
2586 assert_eq!(
2587 record.output_token_count_source,
2588 OutputTokenCountSource::Usage
2589 );
2590 assert_eq!(
2591 record.itl_evidence.eligibility,
2592 ItlEligibility::EventUsageMismatch
2593 );
2594 assert_eq!(record.itl_evidence.output_events, 1);
2595 assert_eq!(record.itl_evidence.usage_output_tokens, Some(3));
2596 }
2597
2598 #[test]
2599 fn matching_sse_events_usage_and_intervals_are_itl_eligible() {
2600 let mut state = StreamState::new(Instant::now(), 7);
2601 for content in ["a", "b", "c"] {
2602 state
2603 .handle_payload(&format!(
2604 r#"{{"choices":[{{"delta":{{"content":"{content}"}}}}]}}"#
2605 ))
2606 .unwrap();
2607 }
2608 state
2609 .handle_payload(r#"{"choices":[],"usage":{"completion_tokens":3}}"#)
2610 .unwrap();
2611 state.done_count = 1;
2612 let record = state.finish();
2613 assert!(record.success);
2614 assert_eq!(record.itl_ms.len(), 2);
2615 assert_eq!(record.itl_evidence.eligibility, ItlEligibility::Eligible);
2616 }
2617
2618 #[tokio::test]
2619 async fn measured_join_error_becomes_failed_evidence() {
2620 let correlation = |request_index| {
2621 BenchmarkRequestCorrelation::new(
2622 "bench-test".to_string(),
2623 "cell-test".to_string(),
2624 0,
2625 BenchmarkPhase::Measured,
2626 request_index,
2627 )
2628 .unwrap()
2629 };
2630 let first_correlation = correlation(0);
2631 let second_correlation = correlation(1);
2632 let good_record_correlation = first_correlation.clone();
2633 let good = tokio::spawn(async move {
2634 RequestRecord {
2635 benchmark_correlation: Some(good_record_correlation),
2636 server_request_id: None,
2637 success: true,
2638 ttft_ms: 1.0,
2639 e2e_ms: 2.0,
2640 input_tokens: 7,
2641 server_input_tokens: None,
2642 output_tokens: 1,
2643 output_token_count_source: OutputTokenCountSource::Usage,
2644 itl_evidence: RequestItlEvidence::sse(true, 1, Some(1), 0, 0),
2645 quality_issues: QualityIssueCounts::default(),
2646 itl_ms: vec![],
2647 }
2648 });
2649 let panic_record_correlation = second_correlation.clone();
2650 let panicked = tokio::spawn(async move {
2651 if true {
2652 panic!("measured task panic");
2653 }
2654 join_failed_record(0, panic_record_correlation)
2655 });
2656 let records = collect_measured_handles(vec![
2657 (7, first_correlation, good),
2658 (11, second_correlation, panicked),
2659 ])
2660 .await;
2661 assert_eq!(records.len(), 2);
2662 assert!(records[0].success);
2663 assert!(!records[1].success);
2664 assert_eq!(records[1].input_tokens, 11);
2665 assert_eq!(records[1].quality_issues.panic, 1);
2666 assert_eq!(
2667 records[1].output_token_count_source,
2668 OutputTokenCountSource::None
2669 );
2670 }
2671
2672 #[test]
2673 fn stream_reasoning_chunk_counts_as_token_event() {
2674 let mut state = StreamState::new(Instant::now(), 7);
2675 state
2676 .handle_payload(r#"{"choices":[{"delta":{"reasoning":"thinking"}}]}"#)
2677 .unwrap();
2678 state.done_count = 1;
2679 let record = state.finish();
2680 assert!(record.success);
2681 assert_eq!(record.output_tokens, 1);
2682 assert_eq!(
2683 record.output_token_count_source,
2684 OutputTokenCountSource::StreamChunks
2685 );
2686 }
2687
2688 #[test]
2689 fn stream_error_after_chunk_fails() {
2690 let mut state = StreamState::new(Instant::now(), 7);
2691 state
2692 .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}]}"#)
2693 .unwrap();
2694 state.stream_error = Some("broken stream".into());
2695 state.done_count = 1;
2696 let record = state.finish();
2697 assert!(!record.success);
2698 assert_eq!(
2699 record.output_token_count_source,
2700 OutputTokenCountSource::StreamChunks
2701 );
2702 }
2703
2704 #[test]
2705 fn openai_error_sse_event_fails_even_when_done_follows() {
2706 let record = parse_sse_chunks([concat!(
2707 "data: {\"error\":{\"message\":\"decode failed\",",
2708 "\"type\":\"internal_server_error\",\"param\":null,\"code\":null}}\n\n",
2709 "data: [DONE]\n\n"
2710 )
2711 .as_bytes()]);
2712
2713 assert!(!record.success);
2714 assert_eq!(record.quality_issues.malformed_stream, 1);
2715 assert_eq!(
2716 record.itl_evidence.eligibility,
2717 ItlEligibility::RequestFailed
2718 );
2719 }
2720
2721 #[test]
2722 fn eof_before_done_after_chunk_fails() {
2723 let mut state = StreamState::new(Instant::now(), 7);
2724 state
2725 .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}]}"#)
2726 .unwrap();
2727 let record = state.finish();
2728 assert!(!record.success);
2729 assert_eq!(record.output_tokens, 1);
2730 assert_eq!(record.quality_issues.missing_done, 1);
2731 }
2732
2733 #[test]
2734 fn malformed_sse_json_fails() {
2735 let mut state = StreamState::new(Instant::now(), 7);
2736 assert!(state.handle_payload("{bad json}").is_err());
2737 state.stream_error = Some("malformed json".into());
2738 state.quality_issues.malformed_stream = 1;
2739 state.done_count = 1;
2740 let record = state.finish();
2741 assert!(!record.success);
2742 assert_eq!(record.output_tokens, 0);
2743 assert_eq!(record.quality_issues.malformed_stream, 1);
2744 }
2745
2746 #[test]
2747 fn done_with_zero_content_tokens_fails() {
2748 let mut state = StreamState::new(Instant::now(), 7);
2749 state.done_count = 1;
2750 let record = state.finish();
2751 assert!(!record.success);
2752 assert_eq!(record.output_tokens, 0);
2753 assert_eq!(record.quality_issues.zero_output_tokens, 1);
2754 assert_eq!(
2755 record.output_token_count_source,
2756 OutputTokenCountSource::None
2757 );
2758 }
2759
2760 #[test]
2761 fn duplicate_done_fails() {
2762 let mut state = StreamState::new(Instant::now(), 7);
2763 state
2764 .handle_payload(r#"{"choices":[{"delta":{"content":"hello"}}]}"#)
2765 .unwrap();
2766 state.done_count = 2;
2767 let record = state.finish();
2768 assert!(!record.success);
2769 assert_eq!(record.quality_issues.duplicate_done, 1);
2770 }
2771
2772 #[test]
2773 fn bad_output_text_fails() {
2774 let mut state = StreamState::new(Instant::now(), 7);
2775 state
2776 .handle_payload(r#"{"choices":[{"delta":{"content":"<unk>"}}]}"#)
2777 .unwrap();
2778 state.done_count = 1;
2779 let record = state.finish();
2780 assert!(!record.success);
2781 assert_eq!(record.quality_issues.bad_output, 1);
2782 }
2783
2784 #[test]
2785 fn mojibake_sequences_fail_but_standalone_leads_do_not() {
2786 assert!(has_bad_output_text("caf\u{00c3}\u{00a9}"));
2787 assert!(has_bad_output_text("copyright \u{00c2}\u{00a9}"));
2788 assert!(has_bad_output_text("quote\u{00e2}\u{20ac}\u{2122}"));
2789 assert!(!has_bad_output_text("\u{00c2}"));
2790 assert!(!has_bad_output_text("\u{00c3}"));
2791 assert!(!has_bad_output_text("Grade \u{00c2} report"));
2792 }
2793
2794 #[test]
2795 fn random_prompt_generation_targets_reencoded_length_when_fixture_is_set() {
2796 let Some(path) = ferrum_env_value("FERRUM_BENCH_TOKENIZER_FIXTURE") else {
2797 return;
2798 };
2799 let tok = tokenizers::Tokenizer::from_file(path).expect("load tokenizer fixture");
2800 let mut rng = StdRng::seed_from_u64(9271);
2801 for _ in 0..16 {
2802 let text = gen_random_prompt(&tok, 256, &mut rng);
2803 assert_eq!(token_count(&tok, &text), Some(256));
2804 assert!(generated_prompt_is_safe(&text));
2805 }
2806
2807 let prompts = gen_shared_prefix_prompts(&tok, 4, 128, 16, &mut rng)
2808 .expect("generate shared-prefix prompts");
2809 assert_eq!(prompts.len(), 4);
2810 assert!(prompts
2811 .iter()
2812 .all(|prompt| generated_prompt_is_safe(&prompt.text)));
2813 }
2814
2815 #[test]
2816 fn generated_prompt_safety_rejects_transport_poisoning_text() {
2817 assert!(generated_prompt_is_safe("hello world\nnext"));
2818 assert!(!generated_prompt_is_safe("bad \u{fffd} text"));
2819 assert!(!generated_prompt_is_safe("<|im_start|>"));
2820 assert!(!generated_prompt_is_safe("control \u{7}"));
2821 }
2822
2823 fn ferrum_env_value(key: &str) -> Option<String> {
2824 ferrum_types::RuntimeConfigSnapshot::capture_current()
2825 .entries
2826 .into_iter()
2827 .find(|entry| entry.key == key)
2828 .map(|entry| entry.effective_value)
2829 }
2830
2831 pub(super) fn test_command() -> BenchServeCommand {
2832 BenchServeCommand {
2833 base_url: "http://127.0.0.1:9".to_string(),
2834 model: "test-model".to_string(),
2835 tokenizer: std::path::PathBuf::from("."),
2836 target_backend: None,
2837 http_connection_mode: BenchHttpConnectionMode::Pooled,
2838 scenario: BenchServeWorkload::Standard,
2839 decode_isolation: DecodeIsolationArgs::default(),
2840 sampling: BenchSamplingArgs::default(),
2841 concurrency: 1,
2842 concurrency_sweep: vec![],
2843 request_rate: None,
2844 dataset: "random".to_string(),
2845 random_input_len: 2,
2846 random_output_len: 3,
2847 ignore_eos: false,
2848 enable_thinking: None,
2849 reasoning_effort: None,
2850 sharegpt_path: None,
2851 shared_prefix_len: 1024,
2852 shared_suffix_len: 64,
2853 num_prompts: 1,
2854 warmup_requests: 0,
2855 n_repeats: 1,
2856 goodput: None,
2857 timeout: 1.0,
2858 fail_on_error: false,
2859 max_error_rate: None,
2860 require_ci: false,
2861 seed: Some(9271),
2862 output: "json".to_string(),
2863 out: None,
2864 hw_id: None,
2865 commit_sha: None,
2866 tag: None,
2867 }
2868 }
2869
2870 fn policy_report(
2871 completed: u32,
2872 errored: u32,
2873 warmup_completed: u32,
2874 warmup_errored: u32,
2875 ) -> BenchReport {
2876 let mut records = Vec::with_capacity((completed + errored) as usize);
2877 for _ in 0..completed {
2878 records.push(RequestRecord {
2879 benchmark_correlation: None,
2880 server_request_id: None,
2881 success: true,
2882 ttft_ms: 10.0,
2883 e2e_ms: 30.0,
2884 input_tokens: 2,
2885 server_input_tokens: None,
2886 output_tokens: 3,
2887 output_token_count_source: OutputTokenCountSource::Usage,
2888 itl_evidence: RequestItlEvidence::sse(true, 3, Some(3), 2, 0),
2889 quality_issues: QualityIssueCounts::default(),
2890 itl_ms: vec![10.0, 10.0],
2891 });
2892 }
2893 for _ in 0..errored {
2894 let mut quality = QualityIssueCounts::default();
2895 quality.missing_done = 1;
2896 records.push(RequestRecord {
2897 benchmark_correlation: None,
2898 server_request_id: None,
2899 success: false,
2900 ttft_ms: 0.0,
2901 e2e_ms: 30.0,
2902 input_tokens: 2,
2903 server_input_tokens: None,
2904 output_tokens: 0,
2905 output_token_count_source: OutputTokenCountSource::None,
2906 itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
2907 quality_issues: quality,
2908 itl_ms: vec![],
2909 });
2910 }
2911 let warmup_expected = warmup_completed.checked_add(warmup_errored).unwrap();
2912 let mut warmup_quality = QualityIssueCounts::default();
2913 warmup_quality.missing_done = warmup_errored;
2914 compute_metrics(
2915 "test-model".to_string(),
2916 "test-backend".to_string(),
2917 Scenario::ClosedLoop,
2918 Some(1),
2919 None,
2920 2,
2921 3,
2922 warmup_expected,
2923 Slo::default(),
2924 vec![RunRecord {
2925 expected_requests: completed.checked_add(errored).unwrap(),
2926 records,
2927 duration_s: 1.0,
2928 warmup: WarmupSummary {
2929 expected: warmup_expected,
2930 completed: warmup_completed,
2931 errored: warmup_errored,
2932 quality_issues: warmup_quality,
2933 },
2934 }],
2935 Env::default(),
2936 )
2937 }
2938
2939 #[test]
2940 fn validate_command_rejects_zero_repeats_and_invalid_programmatic_slo() {
2941 let mut cmd = test_command();
2942 cmd.n_repeats = 0;
2943 assert!(validate_command(&cmd).is_err());
2944 cmd.n_repeats = 1;
2945 cmd.goodput = Some(Slo {
2946 ttft_p99_ms: f64::NAN,
2947 ..Slo::default()
2948 });
2949 assert!(validate_command(&cmd).is_err());
2950 cmd.goodput = None;
2951 cmd.timeout = f64::MAX;
2952 assert!(validate_command(&cmd).is_err());
2953 }
2954
2955 #[test]
2956 fn decode_isolation_rejects_non_random_dataset() {
2957 let mut cmd = test_command();
2958 cmd.scenario = BenchServeWorkload::DecodeIsolation;
2959 cmd.target_backend = Some(BenchTargetBackend::Cuda);
2960 cmd.dataset = "sharegpt".to_string();
2961
2962 let error = validate_command(&cmd).expect_err("dataset must match actual workload");
2963 assert!(error
2964 .to_string()
2965 .contains("decode-isolation requires --dataset random"));
2966 }
2967
2968 #[test]
2969 fn decode_isolation_rejects_ignored_standard_workload_options() {
2970 let mut cmd = test_command();
2971 cmd.scenario = BenchServeWorkload::DecodeIsolation;
2972 cmd.target_backend = Some(BenchTargetBackend::Cuda);
2973 cmd.num_prompts = 12;
2974 let error = validate_command(&cmd).expect_err("num-prompts is not part of this shape");
2975 assert!(error.to_string().contains("derives request count"));
2976
2977 cmd.num_prompts = 100;
2978 cmd.sharegpt_path = Some(PathBuf::from("unused.jsonl"));
2979 let error = validate_command(&cmd).expect_err("unused dataset option must be rejected");
2980 assert!(error.to_string().contains("cannot use ShareGPT"));
2981 }
2982
2983 #[test]
2984 fn measured_error_rate_excludes_successful_warmups() {
2985 let report = policy_report(99, 1, 10, 0);
2986 let mut cmd = test_command();
2987 cmd.max_error_rate = Some(0.0095);
2988 let err = enforce_error_policy(&cmd, &[report]).expect_err("measured rate is one percent");
2989 assert!(err.to_string().contains("bench-serve error rate"));
2990 }
2991
2992 #[test]
2993 fn warmup_failure_is_independent_of_measured_error_allowance() {
2994 let report = policy_report(100, 0, 9, 1);
2995 let mut cmd = test_command();
2996 cmd.max_error_rate = Some(1.0);
2997 let err = enforce_error_policy(&cmd, &[report]).expect_err("warmup must be perfect");
2998 assert!(err.to_string().contains("bench-serve warmup failed"));
2999 }
3000
3001 #[test]
3002 fn fail_on_error_still_writes_json_report() {
3003 let out = std::env::temp_dir().join(format!(
3004 "ferrum-bench-serve-failed-report-{}-{}.json",
3005 std::process::id(),
3006 std::time::SystemTime::now()
3007 .duration_since(std::time::UNIX_EPOCH)
3008 .unwrap()
3009 .as_nanos()
3010 ));
3011 let _ = std::fs::remove_file(&out);
3012
3013 let mut failed_quality = QualityIssueCounts::default();
3014 failed_quality.http_500 = 1;
3015 let run = RunRecord {
3016 records: vec![
3017 RequestRecord {
3018 benchmark_correlation: None,
3019 server_request_id: None,
3020 success: true,
3021 ttft_ms: 10.0,
3022 e2e_ms: 30.0,
3023 input_tokens: 4,
3024 server_input_tokens: None,
3025 output_tokens: 3,
3026 output_token_count_source: OutputTokenCountSource::Usage,
3027 itl_evidence: RequestItlEvidence::sse(true, 3, Some(3), 2, 0),
3028 quality_issues: QualityIssueCounts::default(),
3029 itl_ms: vec![10.0, 10.0],
3030 },
3031 RequestRecord {
3032 benchmark_correlation: None,
3033 server_request_id: None,
3034 success: false,
3035 ttft_ms: 0.0,
3036 e2e_ms: 50.0,
3037 input_tokens: 4,
3038 server_input_tokens: None,
3039 output_tokens: 0,
3040 output_token_count_source: OutputTokenCountSource::None,
3041 itl_evidence: RequestItlEvidence::failed(ItlEvidenceSource::SseDeltaEvents),
3042 quality_issues: failed_quality,
3043 itl_ms: vec![],
3044 },
3045 ],
3046 expected_requests: 2,
3047 duration_s: 1.0,
3048 warmup: Default::default(),
3049 };
3050 let report = compute_metrics(
3051 "test-model".to_string(),
3052 "test-backend".to_string(),
3053 Scenario::ClosedLoop,
3054 Some(2),
3055 None,
3056 2,
3057 3,
3058 0,
3059 Slo::default(),
3060 vec![run],
3061 Env::default(),
3062 );
3063 let cmd = BenchServeCommand {
3064 base_url: "http://127.0.0.1:9".to_string(),
3065 model: "test-model".to_string(),
3066 tokenizer: std::path::PathBuf::from("."),
3067 target_backend: None,
3068 http_connection_mode: BenchHttpConnectionMode::Pooled,
3069 scenario: BenchServeWorkload::Standard,
3070 decode_isolation: DecodeIsolationArgs::default(),
3071 sampling: BenchSamplingArgs::default(),
3072 concurrency: 2,
3073 concurrency_sweep: vec![],
3074 request_rate: None,
3075 dataset: "random".to_string(),
3076 random_input_len: 4,
3077 random_output_len: 3,
3078 ignore_eos: false,
3079 enable_thinking: None,
3080 reasoning_effort: None,
3081 sharegpt_path: None,
3082 shared_prefix_len: 1024,
3083 shared_suffix_len: 64,
3084 num_prompts: 2,
3085 warmup_requests: 0,
3086 n_repeats: 1,
3087 goodput: None,
3088 timeout: 1.0,
3089 fail_on_error: true,
3090 max_error_rate: None,
3091 require_ci: false,
3092 seed: Some(9271),
3093 output: "json".to_string(),
3094 out: Some(out.clone()),
3095 hw_id: None,
3096 commit_sha: None,
3097 tag: None,
3098 };
3099
3100 let err = emit_then_enforce_error_policy(&cmd, &[report]).expect_err("error policy");
3101 assert!(
3102 err.to_string().contains("bench-serve error rate"),
3103 "unexpected error: {err}"
3104 );
3105 let raw = std::fs::read_to_string(&out).expect("report written before error");
3106 let json: serde_json::Value = serde_json::from_str(&raw).expect("json report");
3107 assert_eq!(json["completed_per_run"], serde_json::json!([1]));
3108 assert_eq!(json["errored_per_run"], serde_json::json!([1]));
3109 assert_eq!(json["http_500_per_run"], serde_json::json!([1]));
3110 assert_eq!(
3111 json["output_tokens_per_request"],
3112 serde_json::json!([[3, 0]])
3113 );
3114 let _ = std::fs::remove_file(out);
3115 }
3116
3117 #[test]
3118 fn fail_on_error_rejects_warmup_only_failure() {
3119 let mut warmup_quality = QualityIssueCounts::default();
3120 warmup_quality.missing_done = 1;
3121 let report = compute_metrics(
3122 "test-model".to_string(),
3123 "test-backend".to_string(),
3124 Scenario::ClosedLoop,
3125 Some(1),
3126 None,
3127 2,
3128 3,
3129 1,
3130 Slo::default(),
3131 vec![RunRecord {
3132 records: vec![RequestRecord {
3133 benchmark_correlation: None,
3134 server_request_id: None,
3135 success: true,
3136 ttft_ms: 10.0,
3137 e2e_ms: 30.0,
3138 input_tokens: 2,
3139 server_input_tokens: None,
3140 output_tokens: 3,
3141 output_token_count_source: OutputTokenCountSource::Usage,
3142 itl_evidence: RequestItlEvidence::sse(true, 3, Some(3), 2, 0),
3143 quality_issues: QualityIssueCounts::default(),
3144 itl_ms: vec![10.0, 10.0],
3145 }],
3146 expected_requests: 1,
3147 duration_s: 1.0,
3148 warmup: WarmupSummary {
3149 expected: 1,
3150 completed: 0,
3151 errored: 1,
3152 quality_issues: warmup_quality,
3153 },
3154 }],
3155 Env::default(),
3156 );
3157 let cmd = BenchServeCommand {
3158 base_url: "http://127.0.0.1:9".to_string(),
3159 model: "test-model".to_string(),
3160 tokenizer: std::path::PathBuf::from("."),
3161 target_backend: None,
3162 http_connection_mode: BenchHttpConnectionMode::Pooled,
3163 scenario: BenchServeWorkload::Standard,
3164 decode_isolation: DecodeIsolationArgs::default(),
3165 sampling: BenchSamplingArgs::default(),
3166 concurrency: 1,
3167 concurrency_sweep: vec![],
3168 request_rate: None,
3169 dataset: "random".to_string(),
3170 random_input_len: 2,
3171 random_output_len: 3,
3172 ignore_eos: false,
3173 enable_thinking: None,
3174 reasoning_effort: None,
3175 sharegpt_path: None,
3176 shared_prefix_len: 1024,
3177 shared_suffix_len: 64,
3178 num_prompts: 1,
3179 warmup_requests: 1,
3180 n_repeats: 1,
3181 goodput: None,
3182 timeout: 1.0,
3183 fail_on_error: true,
3184 max_error_rate: None,
3185 require_ci: false,
3186 seed: Some(9271),
3187 output: "json".to_string(),
3188 out: None,
3189 hw_id: None,
3190 commit_sha: None,
3191 tag: None,
3192 };
3193
3194 let err = enforce_error_policy(&cmd, &[report]).expect_err("warmup failure");
3195 assert!(err.to_string().contains("bench-serve warmup failed"));
3196 }
3197}