Skip to main content

sokuto_cli/commands/
benchmark.rs

1//! # 推論レイテンシ・スループット測定ベンチマークコマンド
2//!
3//! ネットワークオーバーヘッドを排除したインプロセスエンジン直接駆動により、
4//! 単一質問、並列バッチ、大規模候補探索 (Coarse-to-Fine)、およびスクラッチパッド再利用時の
5//! 純粋な計算レイテンシとスループットを精密に計測する。
6
7use 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/// ベンチマーク実行構成パラメータ。
23#[derive(Debug, Clone)]
24pub struct BenchmarkArgs {
25    /// モデルファイル格納ディレクトリ。
26    pub model_dir: PathBuf,
27    /// 測定反復回数。
28    pub iterations: usize,
29    /// ウォームアップ反復回数。
30    pub warmup: usize,
31    /// 優先 Execution Provider。
32    pub provider: Option<String>,
33    /// 実行対象シナリオ ("all", "single", "batch", "coarse", "scratchpad")。
34    pub scenario: String,
35    /// セッションプールサイズ。
36    pub pool_size: usize,
37    /// 単一オペレータ内の並列スレッド数 (intra-op)。
38    pub intra_threads: Option<usize>,
39    /// 複数オペレータ間の並列スレッド数 (inter-op)。
40    pub inter_threads: Option<usize>,
41    /// CPU メモリアリーナを有効化するかどうか。
42    pub enable_mem_arena: bool,
43    /// CI 連携用 JSON 形式出力フラグ。
44    pub json: bool,
45}
46
47/// 単一シナリオの計測統計サマリー。
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ScenarioStats {
50    /// シナリオ識別名。
51    pub name: String,
52    /// 評価質問数。
53    pub total_decisions: usize,
54    /// 実行反復回数。
55    pub iterations: usize,
56    /// 最小レイテンシ (ミリ秒)。
57    pub min_ms: f64,
58    /// 平均レイテンシ (ミリ秒)。
59    pub mean_ms: f64,
60    /// 最大レイテンシ (ミリ秒)。
61    pub max_ms: f64,
62    /// 50 パーセンタイル (p50) レイテンシ (ミリ秒)。
63    pub p50_ms: f64,
64    /// 90 パーセンタイル (p90) レイテンシ (ミリ秒)。
65    pub p90_ms: f64,
66    /// 95 パーセンタイル (p95) レイテンシ (ミリ秒)。
67    pub p95_ms: f64,
68    /// 99 パーセンタイル (p99) レイテンシ (ミリ秒)。
69    pub p99_ms: f64,
70    /// スループット (decisions / 秒)。
71    pub throughput_dps: f64,
72}
73
74/// ベンチマーク全体の実行結果。
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct BenchmarkSuiteResult {
77    /// 使用した Execution Provider 名。
78    pub active_provider: String,
79    /// モデルディレクトリ。
80    pub model_dir: String,
81    /// 各シナリオの計測結果一覧。
82    pub scenarios: Vec<ScenarioStats>,
83}
84
85/// 時間測定値配列から統計量を算出する。
86fn 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
121/// パーセンタイル値を算出する。
122fn 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
130/// ベンチマークコマンドを実行する。
131pub fn run_benchmark(args: BenchmarkArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
132    // 1. モデルディレクトリ検証
133    if let Err(err) = validate_model_dir(&args.model_dir) {
134        eprintln!("エラー: {err}");
135        return Err(err.into());
136    }
137
138    // 2. セッション設定の構築
139    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    // --- シナリオ 1: Single Choice (K=3) ---
193    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        // ウォームアップ
210        for _ in 0..args.warmup {
211            let tokenized = tokenizer.encode_question(state, &question)?;
212            let _ = engine.forward_question(&tokenized)?;
213        }
214
215        // 本測定
216        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    // --- シナリオ 2: Single Score (K=5) ---
230    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        // ウォームアップ
245        for _ in 0..args.warmup {
246            let tokenized = tokenizer.encode_question(state, &question)?;
247            let _ = engine.forward_question(&tokenized)?;
248        }
249
250        // 本測定
251        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    // --- シナリオ 3: Single Noul (真偽判定) ---
265    if should_run_single {
266        eprintln!("[3/6] Single Noul (真偽判定) を測定中...");
267        let state = "サーバーの CPU 使用率が 98%、空きメモリが 50MB 未満です。";
268        let question = Question::new_noul("直ちにアラートを発報すべき緊急事態ですか?");
269
270        // ウォームアップ
271        for _ in 0..args.warmup {
272            let tokenized = tokenizer.encode_question(state, &question)?;
273            let _ = engine.forward_question(&tokenized)?;
274        }
275
276        // 本測定
277        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    // --- シナリオ 4: Batch Scaling (4, 8, 16 Questions) ---
291    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            // ウォームアップ
309            for _ in 0..args.warmup {
310                let batch = tokenizer.encode_batch_questions(state, &questions)?;
311                let _ = engine.forward_batch_tokenized(&batch)?;
312            }
313
314            // 本測定
315            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    // --- シナリオ 5: Coarse-to-Fine (K=77 大規模候補探索) ---
332    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        // ウォームアップ
351        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    // --- シナリオ 6: Scratchpad 再利用によるアロケーション抑制効果 ---
388    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        // ウォームアップ
405        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        // 本測定
416        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    // 4. 結果の出力
435    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
445/// 計測結果を人間向けの整形テーブルとして標準出力に描画する。
446fn 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}