use std::path::PathBuf;
use std::time::Instant;
use clap::{Parser, ValueEnum};
use rapid_structure_rs::{
FormulaPreprocessor, FormulaSession, FormulaTokenizer, FormulaTokenizerMetadata,
ProviderPreference, RuntimeConfig, VerifiedModel, sha256_file,
};
use serde::Serialize;
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ProviderArg {
Cpu,
Directml,
Cuda,
}
#[derive(Debug, Parser)]
#[command(
name = "formula_bench",
about = "Benchmark PP-FormulaNet_plus with per-stage latency statistics, providers and peak memory"
)]
struct Cli {
#[arg(long)]
model: PathBuf,
#[arg(long = "image", required = true, num_args = 1..)]
images: Vec<PathBuf>,
#[arg(long, default_value_t = 5)]
rounds: usize,
#[arg(long, default_value_t = 1)]
warmup: usize,
#[arg(long = "batch-sizes", value_delimiter = ',', default_value = "1,2,4,8")]
batch_sizes: Vec<usize>,
#[arg(long, value_enum, default_value_t = ProviderArg::Cpu)]
provider: ProviderArg,
#[arg(long)]
threads: Option<usize>,
#[arg(long)]
expected_sha256: Option<String>,
#[arg(long = "ocr-baseline")]
ocr_baseline: Option<PathBuf>,
#[arg(long)]
output: Option<PathBuf>,
}
use rapid_structure_rs::evaluation::stats::Stats;
use rapid_structure_rs::{peak_memory_source, peak_working_set_bytes};
#[derive(Debug, Serialize)]
struct StageStats {
preprocess: Stats,
session_run: Stats,
tokenizer_decode: Stats,
end_to_end: Stats,
}
impl StageStats {
fn from_rounds(rounds: &[RoundSample]) -> Self {
Self {
preprocess: Stats::from_samples(rounds.iter().map(|r| r.preprocess_ms).collect()),
session_run: Stats::from_samples(rounds.iter().map(|r| r.run_ms).collect()),
tokenizer_decode: Stats::from_samples(rounds.iter().map(|r| r.decode_ms).collect()),
end_to_end: Stats::from_samples(rounds.iter().map(|r| r.e2e_ms).collect()),
}
}
}
#[derive(Debug, Clone, Copy)]
struct RoundSample {
preprocess_ms: f64,
run_ms: f64,
decode_ms: f64,
e2e_ms: f64,
}
#[derive(Debug, Serialize)]
struct BatchReport {
batch: usize,
per_image_e2e_ms: Stats,
batch_e2e_ms: Stats,
stages: StageStats,
deterministic_tokens: bool,
latex: String,
}
#[derive(Debug, Serialize)]
struct MemoryReport {
peak_working_set_start_bytes: Option<u64>,
peak_working_set_after_session_bytes: Option<u64>,
peak_working_set_end_bytes: Option<u64>,
peak_working_set_delta_bytes: Option<u64>,
source: &'static str,
}
#[derive(Debug, Serialize)]
struct ModelReport {
path: String,
file_name: String,
size_bytes: u64,
sha256: Option<String>,
}
#[derive(Debug, Serialize)]
struct ThreadReport {
intra_threads: Option<usize>,
inter_threads: Option<usize>,
auto_tune_threads: bool,
effective_intra_threads: Option<usize>,
effective_inter_threads: Option<usize>,
logical_cpus: Option<usize>,
physical_cpus: usize,
}
#[derive(Debug, Serialize)]
struct Report {
tool: &'static str,
provider_requested: String,
provider_selected_ep: String,
provider_fallback_used: bool,
rounds: usize,
warmup: usize,
model: ModelReport,
images: Vec<String>,
image_size: Option<[u32; 2]>,
threads: ThreadReport,
session_create_ms: f64,
first_inference_ms: f64,
warm_single: StageStats,
batches: Vec<BatchReport>,
memory: MemoryReport,
ocr_baseline: Option<serde_json::Value>,
notes: Vec<String>,
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {error}");
std::process::exit(1);
}
}
fn runtime_config(cli: &Cli) -> RuntimeConfig {
RuntimeConfig {
provider_preference: match cli.provider {
ProviderArg::Cpu => ProviderPreference::Cpu,
ProviderArg::Directml => ProviderPreference::DirectMl { device_id: 0 },
ProviderArg::Cuda => ProviderPreference::Cuda { device_id: 0 },
},
fail_if_provider_unavailable: true,
intra_threads: cli.threads,
auto_tune_threads: cli.threads.is_none(),
..RuntimeConfig::default()
}
}
fn ms(started: Instant) -> f64 {
started.elapsed().as_secs_f64() * 1000.0
}
#[derive(Debug, Clone)]
struct Measurement {
sample: RoundSample,
tokens: Vec<Vec<i64>>,
}
fn eos_prefix(token_ids: &[i64], eos_index: Option<usize>) -> Vec<i64> {
match eos_index {
Some(index) if index < token_ids.len() => token_ids[..=index].to_vec(),
_ => token_ids.to_vec(),
}
}
fn batch_matches_baselines(baselines: &[Vec<i64>], rows: &[Vec<i64>]) -> bool {
if baselines.is_empty() {
return false;
}
rows.iter()
.enumerate()
.all(|(index, row)| *row == baselines[index % baselines.len()])
}
fn measure(
preprocessor: &FormulaPreprocessor,
session: &mut FormulaSession,
tokenizer: &FormulaTokenizer,
images: &[image::DynamicImage],
) -> Result<Measurement, Box<dyn std::error::Error>> {
let preprocess_start = Instant::now();
let tensor = preprocessor.preprocess_batch(images)?;
let preprocess_ms = ms(preprocess_start);
let run_start = Instant::now();
let output = session.run(tensor.view())?;
let run_ms = ms(run_start);
let decode_start = Instant::now();
let mut tokens = Vec::with_capacity(images.len());
for row in output.axis_iter(ndarray::Axis(0)) {
let row = row.to_vec();
let decoded = tokenizer.decode_ids(&row)?;
tokens.push(eos_prefix(&decoded.token_ids, decoded.eos_index));
}
let decode_ms = ms(decode_start);
Ok(Measurement {
sample: RoundSample {
preprocess_ms,
run_ms,
decode_ms,
e2e_ms: preprocess_ms + run_ms + decode_ms,
},
tokens,
})
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
if cli.rounds == 0 {
return Err("--rounds must be greater than zero".into());
}
if cli.batch_sizes.contains(&0) {
return Err("--batch-sizes entries must be greater than zero".into());
}
let memory_start = peak_working_set_bytes();
let runtime = runtime_config(&cli);
let images = cli
.images
.iter()
.map(image::open)
.collect::<Result<Vec<_>, _>>()?;
if images.is_empty() {
return Err("--image requires at least one readable image".into());
}
let max_batch = images
.len()
.max(*cli.batch_sizes.iter().max().unwrap_or(&1));
let session_start = Instant::now();
let mut session = FormulaSession::new(
&VerifiedModel::verify(&cli.model, cli.expected_sha256.as_deref())?,
&runtime,
)?;
let session_create_ms = ms(session_start);
let session_threads = session.session_threads();
let memory_after_session = peak_working_set_bytes();
let resolution = session.provider_resolution();
let character_metadata = session
.character_metadata()?
.ok_or("model has no `character` metadata")?;
let metadata = FormulaTokenizerMetadata::from_character_metadata(&character_metadata)?;
let tokenizer = FormulaTokenizer::from_metadata(&metadata)?;
let preprocessor = FormulaPreprocessor::new();
let first_input = preprocessor.preprocess(&images[0])?;
let first_start = Instant::now();
let first_output = session.run(first_input.view())?;
let first_inference_ms = ms(first_start);
let mut baselines: Vec<Vec<i64>> = Vec::with_capacity(images.len());
let first_decoded = tokenizer.decode_ids(&first_output.row(0).to_vec())?;
let baseline_latex = first_decoded.latex.clone();
baselines.push(eos_prefix(
&first_decoded.token_ids,
first_decoded.eos_index,
));
for image in images.iter().skip(1) {
let input = preprocessor.preprocess(image)?;
let output = session.run(input.view())?;
let decoded = tokenizer.decode_ids(&output.row(0).to_vec())?;
baselines.push(eos_prefix(&decoded.token_ids, decoded.eos_index));
}
for _ in 0..cli.warmup {
let _ = measure(&preprocessor, &mut session, &tokenizer, &images[..1])?;
}
let mut single_rounds = Vec::with_capacity(cli.rounds);
for round in 0..cli.rounds {
let image = &images[round % images.len()];
let measurement = measure(
&preprocessor,
&mut session,
&tokenizer,
std::slice::from_ref(image),
)?;
single_rounds.push(measurement.sample);
}
let mut batches = Vec::new();
for &batch in &cli.batch_sizes {
if batch > max_batch {
continue;
}
let selected: Vec<image::DynamicImage> = (0..batch)
.map(|index| images[index % images.len()].clone())
.collect();
for _ in 0..cli.warmup {
let _ = measure(&preprocessor, &mut session, &tokenizer, &selected)?;
}
let mut rounds = Vec::with_capacity(cli.rounds);
let mut deterministic = true;
for _ in 0..cli.rounds {
let measurement = measure(&preprocessor, &mut session, &tokenizer, &selected)?;
rounds.push(measurement.sample);
if !batch_matches_baselines(&baselines, &measurement.tokens) {
deterministic = false;
}
}
batches.push(BatchReport {
batch,
per_image_e2e_ms: Stats::from_samples(
rounds.iter().map(|r| r.e2e_ms / batch as f64).collect(),
),
batch_e2e_ms: Stats::from_samples(rounds.iter().map(|r| r.e2e_ms).collect()),
stages: StageStats::from_rounds(&rounds),
deterministic_tokens: deterministic,
latex: baseline_latex.clone(),
});
}
let ocr_baseline = match &cli.ocr_baseline {
Some(path) => Some(serde_json::from_str(&std::fs::read_to_string(path)?)?),
None => None,
};
let memory_end = peak_working_set_bytes();
let image_size = {
use image::GenericImageView;
let (width, height) = images[0].dimensions();
Some([width, height])
};
let mut notes = vec![
"公式模型的吞吐与普通 OCR 分开报告;`ocr_baseline` 仅作并列展示,不做加权合并。"
.to_string(),
"`deterministic_tokens` 逐行比较 batch 输出与同图单图推理的 token 序列;batch 不应改变结果。"
.to_string(),
];
if resolution.fallback_used {
notes.push(
"provider 发生 CPU 回退;benchmark 已用 fail_if_provider_unavailable=true,\
出现该状态说明环境与配置不一致。"
.to_string(),
);
}
if memory_end.is_none() {
notes.push(format!(
"峰值工作集采集失败:{}",
rapid_structure_rs::peak_memory_failure_reason()
.unwrap_or_else(|| "GetProcessMemoryInfo 未返回数据".to_string())
));
}
let report = Report {
tool: "formula_bench",
provider_requested: format!("{:?}", cli.provider),
provider_selected_ep: format!("{:?}", resolution.selected_ep),
provider_fallback_used: resolution.fallback_used,
rounds: cli.rounds,
warmup: cli.warmup,
model: ModelReport {
path: cli.model.display().to_string(),
file_name: cli
.model
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default(),
size_bytes: std::fs::metadata(&cli.model)?.len(),
sha256: Some(sha256_file(&cli.model)?),
},
images: cli
.images
.iter()
.map(|path| path.display().to_string())
.collect(),
image_size,
threads: ThreadReport {
intra_threads: runtime.intra_threads,
inter_threads: runtime.inter_threads,
auto_tune_threads: runtime.auto_tune_threads,
effective_intra_threads: session_threads.0,
effective_inter_threads: session_threads.1,
logical_cpus: std::thread::available_parallelism().ok().map(|v| v.get()),
physical_cpus: num_cpus::get_physical(),
},
session_create_ms,
first_inference_ms,
warm_single: StageStats::from_rounds(&single_rounds),
batches,
memory: MemoryReport {
peak_working_set_start_bytes: memory_start,
peak_working_set_after_session_bytes: memory_after_session,
peak_working_set_end_bytes: memory_end,
peak_working_set_delta_bytes: match (memory_start, memory_end) {
(Some(start), Some(end)) => Some(end.saturating_sub(start)),
_ => None,
},
source: peak_memory_source(),
},
ocr_baseline,
notes,
};
let text = serde_json::to_string_pretty(&report)?;
if let Some(path) = &cli.output {
std::fs::write(path, &text)?;
}
println!("{text}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::{Stats, batch_matches_baselines, eos_prefix};
#[test]
fn stats_are_wired_to_the_shared_implementation() {
let stats = Stats::from_samples(vec![4.0, 1.0, 3.0, 2.0]);
assert_eq!(stats.samples, 4);
assert!((stats.mean_ms - 2.5).abs() < 1e-12);
assert!((stats.p50_ms - 2.5).abs() < 1e-12);
assert!(Stats::from_samples(Vec::new()).samples == 0);
}
#[test]
fn eos_prefix_truncates_at_eos_and_keeps_unanchored_sequences() {
assert_eq!(
eos_prefix(&[0, 82, 1769, 2, 1, 1], Some(3)),
vec![0, 82, 1769, 2]
);
assert_eq!(eos_prefix(&[0, 82, 1769], None), vec![0, 82, 1769]);
assert_eq!(eos_prefix(&[0, 1], Some(9)), vec![0, 1]);
assert_eq!(eos_prefix(&[], None), Vec::<i64>::new());
}
#[test]
fn batch_determinism_compares_eos_prefixes_per_image() {
let baselines = vec![vec![0, 82, 2], vec![0, 99, 2]];
assert!(batch_matches_baselines(
&baselines,
&[vec![0, 82, 2], vec![0, 99, 2]]
));
assert!(!batch_matches_baselines(
&baselines,
&[vec![0, 99, 2], vec![0, 82, 2]]
));
assert!(batch_matches_baselines(
&baselines,
&[vec![0, 82, 2], vec![0, 99, 2], vec![0, 82, 2]]
));
assert!(!batch_matches_baselines(
&baselines,
&[vec![0, 82, 2], vec![0, 98, 2]]
));
assert!(!batch_matches_baselines(&[], &[vec![0, 2]]));
}
#[test]
fn padding_after_eos_does_not_count_as_a_difference() {
let single = [0, 82, 1769, 2];
let padded_batch_row = [0, 82, 1769, 2, 1, 1, 1, 1];
let baselines = vec![eos_prefix(&single, Some(3))];
let rows = vec![eos_prefix(&padded_batch_row, Some(3))];
assert!(
batch_matches_baselines(&baselines, &rows),
"padding after EOS must be ignored"
);
assert_ne!(single.to_vec(), padded_batch_row.to_vec());
}
}