1use std::fs;
8use std::path::PathBuf;
9use std::time::Instant;
10
11use indexmap::IndexMap;
12use serde::{Deserialize, Serialize};
13
14use sokuto_core::contract::calibration::CalibrationConfig;
15use sokuto_core::schema::Question;
16use sokuto_runtime::engine::coarse::CoarseToFineConfig;
17use sokuto_runtime::engine::{BatchScratchpad, ExecutionProvider, InferenceEngine, SessionConfig};
18use sokuto_runtime::tokenizer::JevTokenizer;
19
20use crate::commands::serve::{parse_provider, validate_model_dir};
21
22#[derive(Debug, Clone)]
24pub struct BenchmarkArgs {
25 pub model_dir: PathBuf,
27 pub iterations: usize,
29 pub warmup: usize,
31 pub provider: Option<String>,
33 pub scenario: String,
35 pub pool_size: usize,
37 pub intra_threads: Option<usize>,
39 pub inter_threads: Option<usize>,
41 pub enable_mem_arena: bool,
43 pub json: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ScenarioStats {
50 pub name: String,
52 pub total_decisions: usize,
54 pub iterations: usize,
56 pub min_ms: f64,
58 pub mean_ms: f64,
60 pub max_ms: f64,
62 pub p50_ms: f64,
64 pub p90_ms: f64,
66 pub p95_ms: f64,
68 pub p99_ms: f64,
70 pub throughput_dps: f64,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct BenchmarkSuiteResult {
77 pub active_provider: String,
79 pub model_dir: String,
81 pub scenarios: Vec<ScenarioStats>,
83}
84
85fn compute_stats(name: &str, decisions_per_run: usize, durations_ms: &mut [f64]) -> ScenarioStats {
87 durations_ms.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
88 let n = durations_ms.len();
89
90 let min_ms = durations_ms.first().copied().unwrap_or(0.0);
91 let max_ms = durations_ms.last().copied().unwrap_or(0.0);
92 let sum: f64 = durations_ms.iter().sum();
93 let mean_ms = if n > 0 { sum / (n as f64) } else { 0.0 };
94
95 let p50_ms = percentile(durations_ms, 50.0);
96 let p90_ms = percentile(durations_ms, 90.0);
97 let p95_ms = percentile(durations_ms, 95.0);
98 let p99_ms = percentile(durations_ms, 99.0);
99
100 let throughput_dps = if mean_ms > 0.0 {
101 (decisions_per_run as f64) / (mean_ms / 1000.0)
102 } else {
103 0.0
104 };
105
106 ScenarioStats {
107 name: name.to_string(),
108 total_decisions: decisions_per_run * n,
109 iterations: n,
110 min_ms,
111 mean_ms,
112 max_ms,
113 p50_ms,
114 p90_ms,
115 p95_ms,
116 p99_ms,
117 throughput_dps,
118 }
119}
120
121fn percentile(sorted: &[f64], p: f64) -> f64 {
123 if sorted.is_empty() {
124 return 0.0;
125 }
126 let idx = ((p / 100.0) * ((sorted.len() - 1) as f64)).round() as usize;
127 sorted[idx.min(sorted.len() - 1)]
128}
129
130pub fn run_benchmark(args: BenchmarkArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
132 if let Err(err) = validate_model_dir(&args.model_dir) {
134 eprintln!("エラー: {err}");
135 return Err(err.into());
136 }
137
138 let mut session_config = SessionConfig {
140 pool_size: args.pool_size.max(1),
141 intra_threads: args.intra_threads,
142 inter_threads: args.inter_threads.or(Some(1)),
143 enable_mem_arena: args.enable_mem_arena,
144 ..Default::default()
145 };
146 if let Some(ref provider_str) = args.provider {
147 let provider = parse_provider(provider_str)?;
148 session_config.preferred_providers = match provider {
149 ExecutionProvider::Auto => SessionConfig::default().preferred_providers,
150 other => vec![other, ExecutionProvider::CPU],
151 };
152 }
153
154 let onnx_path = args.model_dir.join("model.onnx");
155 let tok_path = args.model_dir.join("tokenizer.json");
156 let calib_path = args.model_dir.join("calibration.json");
157
158 eprintln!(
159 "推論エンジンおよびトークナイザーをロード中 (パス: {})...",
160 args.model_dir.display()
161 );
162
163 let engine = InferenceEngine::new(&onnx_path, session_config)?;
164 let tokenizer = JevTokenizer::from_file(&tok_path)?;
165
166 let calib_config = if calib_path.exists() {
167 let calib_json = fs::read_to_string(&calib_path)?;
168 CalibrationConfig::from_json_str(&calib_json)?
169 } else {
170 CalibrationConfig::default()
171 };
172
173 let active_provider = engine.active_provider().name().to_string();
174 eprintln!("アクティブ Execution Provider: {active_provider}");
175 eprintln!(
176 "測定設定: 反復回数={}, ウォームアップ={}, シナリオ={}",
177 args.iterations, args.warmup, args.scenario
178 );
179
180 let mut suite_results = BenchmarkSuiteResult {
181 active_provider: active_provider.clone(),
182 model_dir: args.model_dir.display().to_string(),
183 scenarios: Vec::new(),
184 };
185
186 let scenario_filter = args.scenario.to_lowercase();
187 let should_run_single = scenario_filter == "all" || scenario_filter == "single";
188 let should_run_batch = scenario_filter == "all" || scenario_filter == "batch";
189 let should_run_coarse = scenario_filter == "all" || scenario_filter == "coarse";
190 let should_run_scratch = scenario_filter == "all" || scenario_filter == "scratchpad";
191
192 if should_run_single {
194 eprintln!("[1/6] Single Choice (K=3) を測定中...");
195 let state =
196 "顧客から注文キャンセルのリクエストを受信しました。注文ステータスは発送準備中です。";
197 let mut criteria = IndexMap::new();
198 criteria.insert(
199 "cancel_order".to_string(),
200 "注文を直ちにキャンセルする".to_string(),
201 );
202 criteria.insert(
203 "contact_support".to_string(),
204 "サポート窓口へ転送する".to_string(),
205 );
206 criteria.insert("ignore".to_string(), "特に対処せず保留する".to_string());
207 let question = Question::new_choice("適切な対応を選択してください。", criteria);
208
209 for _ in 0..args.warmup {
211 let tokenized = tokenizer.encode_question(state, &question)?;
212 let _ = engine.forward_question(&tokenized)?;
213 }
214
215 let mut durations = Vec::with_capacity(args.iterations);
217 for _ in 0..args.iterations {
218 let start = Instant::now();
219 let tokenized = tokenizer.encode_question(state, &question)?;
220 let _ = engine.forward_question(&tokenized)?;
221 durations.push(start.elapsed().as_secs_f64() * 1000.0);
222 }
223
224 suite_results
225 .scenarios
226 .push(compute_stats("Single Choice (K=3)", 1, &mut durations));
227 }
228
229 if should_run_single {
231 eprintln!("[2/6] Single Score (K=5) を測定中...");
232 let state =
233 "過去 5 分間に不正なパスワード試行が同一 IP アドレスから 200 回検知されました。";
234 let criteria = vec![
235 "極めて安全 (影響なし)".to_string(),
236 "低リスク (要注視)".to_string(),
237 "中リスク (調査推奨)".to_string(),
238 "高リスク (要即時対応)".to_string(),
239 "致命的インシデント (全遮断)".to_string(),
240 ];
241 let question =
242 Question::new_score("セキュリティ脅威の深刻度を段階評価してください。", criteria);
243
244 for _ in 0..args.warmup {
246 let tokenized = tokenizer.encode_question(state, &question)?;
247 let _ = engine.forward_question(&tokenized)?;
248 }
249
250 let mut durations = Vec::with_capacity(args.iterations);
252 for _ in 0..args.iterations {
253 let start = Instant::now();
254 let tokenized = tokenizer.encode_question(state, &question)?;
255 let _ = engine.forward_question(&tokenized)?;
256 durations.push(start.elapsed().as_secs_f64() * 1000.0);
257 }
258
259 suite_results
260 .scenarios
261 .push(compute_stats("Single Score (K=5)", 1, &mut durations));
262 }
263
264 if should_run_single {
266 eprintln!("[3/6] Single Noul (真偽判定) を測定中...");
267 let state = "サーバーの CPU 使用率が 98%、空きメモリが 50MB 未満です。";
268 let question = Question::new_noul("直ちにアラートを発報すべき緊急事態ですか?");
269
270 for _ in 0..args.warmup {
272 let tokenized = tokenizer.encode_question(state, &question)?;
273 let _ = engine.forward_question(&tokenized)?;
274 }
275
276 let mut durations = Vec::with_capacity(args.iterations);
278 for _ in 0..args.iterations {
279 let start = Instant::now();
280 let tokenized = tokenizer.encode_question(state, &question)?;
281 let _ = engine.forward_question(&tokenized)?;
282 durations.push(start.elapsed().as_secs_f64() * 1000.0);
283 }
284
285 suite_results
286 .scenarios
287 .push(compute_stats("Single Noul (Binary)", 1, &mut durations));
288 }
289
290 if should_run_batch {
292 eprintln!("[4/6] Batch Scaling (並列質問バッチ) を測定中...");
293 let state = "ユーザーが新規アカウント登録を行い、プロファイル情報を入力しました。";
294
295 for &batch_size in &[4, 8, 16] {
296 let mut questions = IndexMap::with_capacity(batch_size);
297 for i in 0..batch_size {
298 let mut c = IndexMap::new();
299 c.insert(format!("approve_{i}"), "承認する".to_string());
300 c.insert(format!("review_{i}"), "審査に回す".to_string());
301 c.insert(format!("reject_{i}"), "却下する".to_string());
302 questions.insert(
303 format!("q_{i}"),
304 Question::new_choice(format!("質問項目 {i} の判定を行ってください。"), c),
305 );
306 }
307
308 for _ in 0..args.warmup {
310 let batch = tokenizer.encode_batch_questions(state, &questions)?;
311 let _ = engine.forward_batch_tokenized(&batch)?;
312 }
313
314 let mut durations = Vec::with_capacity(args.iterations);
316 for _ in 0..args.iterations {
317 let start = Instant::now();
318 let batch = tokenizer.encode_batch_questions(state, &questions)?;
319 let _ = engine.forward_batch_tokenized(&batch)?;
320 durations.push(start.elapsed().as_secs_f64() * 1000.0);
321 }
322
323 suite_results.scenarios.push(compute_stats(
324 &format!("Batch Scaling (Q={batch_size})"),
325 batch_size,
326 &mut durations,
327 ));
328 }
329 }
330
331 if should_run_coarse {
333 eprintln!("[5/6] Coarse-to-Fine (K=77 大規模候補) を測定中...");
334 let state =
335 "ATM から現金を引き出そうとしたところ、カードが取り込まれて返却されませんでした。";
336 let mut large_criteria = IndexMap::with_capacity(77);
337 for i in 0..77 {
338 large_criteria.insert(
339 format!("category_{i:02}"),
340 format!("バンキング意図カテゴリ {i:02} に関する手続き"),
341 );
342 }
343 let question = Question::new_choice("問い合わせの意図を分類してください。", large_criteria);
344 let coarse_config = CoarseToFineConfig {
345 threshold: 30,
346 top_m: 16,
347 ..Default::default()
348 };
349
350 for _ in 0..args.warmup.min(5) {
352 let _ = engine.evaluate_question_coarse_to_fine(
353 &tokenizer,
354 state,
355 &question,
356 &calib_config,
357 &coarse_config,
358 )?;
359 }
360
361 let iterations = if scenario_filter == "all" && args.iterations > 20 {
362 eprintln!(
363 "Coarse-to-Fine シナリオは高負荷のため、全体ベンチマーク時は反復回数を 20 回に制限して実行します (個別実行時は --scenario coarse で全数実行可能)。"
364 );
365 20
366 } else {
367 args.iterations
368 };
369 let mut durations = Vec::with_capacity(iterations);
370 for _ in 0..iterations {
371 let start = Instant::now();
372 let _ = engine.evaluate_question_coarse_to_fine(
373 &tokenizer,
374 state,
375 &question,
376 &calib_config,
377 &coarse_config,
378 )?;
379 durations.push(start.elapsed().as_secs_f64() * 1000.0);
380 }
381
382 suite_results
383 .scenarios
384 .push(compute_stats("Coarse-to-Fine (K=77)", 1, &mut durations));
385 }
386
387 if should_run_scratch {
389 eprintln!("[6/6] Scratchpad 再利用効果を測定中 (Q=8)...");
390 let state = "注文履歴の確認と配送日時の変更手続きを受け付けました。";
391 let mut questions = IndexMap::with_capacity(8);
392 for i in 0..8 {
393 let mut c = IndexMap::new();
394 c.insert("allow".to_string(), "変更を許可する".to_string());
395 c.insert("deny".to_string(), "変更を拒否する".to_string());
396 questions.insert(
397 format!("check_{i}"),
398 Question::new_choice("変更可否を判定してください。", c),
399 );
400 }
401
402 let mut scratchpad = BatchScratchpad::default();
403
404 for _ in 0..args.warmup {
406 let _ = engine.evaluate_batch_questions_with_scratchpad(
407 &tokenizer,
408 state,
409 &questions,
410 &calib_config,
411 &mut scratchpad,
412 )?;
413 }
414
415 let mut durations = Vec::with_capacity(args.iterations);
417 for _ in 0..args.iterations {
418 let start = Instant::now();
419 let _ = engine.evaluate_batch_questions_with_scratchpad(
420 &tokenizer,
421 state,
422 &questions,
423 &calib_config,
424 &mut scratchpad,
425 )?;
426 durations.push(start.elapsed().as_secs_f64() * 1000.0);
427 }
428
429 suite_results
430 .scenarios
431 .push(compute_stats("Scratchpad (Q=8)", 8, &mut durations));
432 }
433
434 if args.json {
436 let json_output = serde_json::to_string_pretty(&suite_results)?;
437 println!("{json_output}");
438 } else {
439 render_table(&suite_results);
440 }
441
442 Ok(())
443}
444
445fn render_table(results: &BenchmarkSuiteResult) {
447 println!();
448 println!(
449 "========================================================================================================="
450 );
451 println!(
452 " Sokuto 推論性能ベンチマーク (EP: {}, モデル: {})",
453 results.active_provider, results.model_dir
454 );
455 println!(
456 "========================================================================================================="
457 );
458 println!(
459 "{:<28} | {:>6} | {:>8} | {:>8} | {:>8} | {:>8} | {:>8} | {:>14}",
460 "シナリオ", "反復", "Min(ms)", "Mean(ms)", "P50(ms)", "P90(ms)", "P99(ms)", "Throughput"
461 );
462 println!(
463 "---------------------------------------------------------------------------------------------------------"
464 );
465
466 for s in &results.scenarios {
467 let throughput_str = format!("{:.1} dec/s", s.throughput_dps);
468 println!(
469 "{:<28} | {:>6} | {:>8.2} | {:>8.2} | {:>8.2} | {:>8.2} | {:>8.2} | {:>14}",
470 s.name, s.iterations, s.min_ms, s.mean_ms, s.p50_ms, s.p90_ms, s.p99_ms, throughput_str
471 );
472 }
473 println!(
474 "========================================================================================================="
475 );
476 println!();
477}