""" core/performance/history.py — Benchmark 歷史記錄模組。 提供 PerformanceHistory 類別,負責: - 將 BenchmarkResult 以 JSON 格式持久化到本地磁碟。 - 依條件(limit / mode)查詢歷史記錄。 - 產生兩次測試間的回歸比較報告。 儲存格式範例: { "records": [ { "id": "benchmark_20260405_143022", "mode": "parallel", "fps": 45.2, "avg_latency_ms": 22.1, "p95_latency_ms": 35.0, "total_frames": 1356, "timestamp": 1743856222.0, "device_config": {"KL720": 2} } ] } """ import json import logging import os import time from datetime import datetime from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) from .benchmarker import BenchmarkResult class PerformanceHistory: """本地 Benchmark 歷史記錄管理器。 屬性: storage_path: JSON 儲存檔案的完整路徑。 預設為 ``~/.cluster4npu/benchmark_history.json``。 """ DEFAULT_STORAGE_PATH = os.path.join( os.path.expanduser("~"), ".cluster4npu", "benchmark_history.json" ) def __init__(self, storage_path: str = DEFAULT_STORAGE_PATH): """初始化 PerformanceHistory。 若儲存目錄不存在,會自動建立。 參數: storage_path: JSON 儲存檔案路徑。 """ self.storage_path = storage_path self._ensure_storage_directory() # ------------------------------------------------------------------ # 公開介面 # ------------------------------------------------------------------ def record(self, result: BenchmarkResult) -> None: """記錄一筆 BenchmarkResult 並持久化至 JSON。 此方法會: 1. 為結果產生唯一 id(若尚未有 id)。 2. 將 id 寫回 result.id。 3. 追加到 JSON 儲存。 參數: result: 要記錄的 BenchmarkResult。 """ data = self._load_raw() # 產生唯一 id record_id = self._generate_id(result) result.id = record_id record_dict = self._result_to_dict(result) data["records"].append(record_dict) self._save_raw(data) def get_history( self, limit: int = 50, mode: Optional[str] = None, ) -> List[BenchmarkResult]: """查詢歷史記錄。 回傳最新優先(reverse chronological)的記錄列表。 參數: limit: 最多回傳幾筆,預設 50。 mode: 若指定,只回傳符合 mode 的記錄('sequential' 或 'parallel')。 回傳: List[BenchmarkResult],最新的記錄排在最前面。 """ data = self._load_raw() records = data.get("records", []) # 過濾 mode if mode is not None: records = [r for r in records if r.get("mode") == mode] # 最新優先(依 timestamp 降序) records = sorted(records, key=lambda r: r.get("timestamp", 0), reverse=True) # 套用 limit records = records[:limit] return [self._dict_to_result(r) for r in records] def get_regression_report( self, baseline_id: str, compare_id: str, ) -> Dict[str, Any]: """比較兩次測試的效能差異,產生回歸報告。 參數: baseline_id: 基準測試的 id。 compare_id: 比較測試的 id。 回傳: 包含以下鍵的字典: - baseline: BenchmarkResult(基準) - compare: BenchmarkResult(比較對象) - fps_change_pct: FPS 變化百分比(正值為改善) - avg_latency_change_pct: 平均延遲變化百分比(負值為改善) - p95_latency_change_pct: P95 延遲變化百分比(負值為改善) 引發: ValueError: 若任一 id 不存在於歷史記錄中。 """ data = self._load_raw() all_records = {r["id"]: r for r in data.get("records", [])} if baseline_id not in all_records: raise ValueError(f"找不到基準測試 id:{baseline_id}") if compare_id not in all_records: raise ValueError(f"找不到比較測試 id:{compare_id}") baseline = self._dict_to_result(all_records[baseline_id]) compare = self._dict_to_result(all_records[compare_id]) def pct_change(old: float, new: float) -> float: """計算相對變化百分比。""" if old == 0: return 0.0 return (new - old) / old * 100.0 return { "baseline": baseline, "compare": compare, "fps_change_pct": pct_change(baseline.fps, compare.fps), "avg_latency_change_pct": pct_change( baseline.avg_latency_ms, compare.avg_latency_ms ), "p95_latency_change_pct": pct_change( baseline.p95_latency_ms, compare.p95_latency_ms ), } # ------------------------------------------------------------------ # 內部實作 # ------------------------------------------------------------------ def _ensure_storage_directory(self) -> None: """若儲存目錄不存在,自動建立。""" parent_dir = os.path.dirname(self.storage_path) if parent_dir: os.makedirs(parent_dir, exist_ok=True) def _load_raw(self) -> Dict[str, Any]: """從 JSON 檔案讀取原始資料。若檔案不存在或損毀,回傳空結構。""" if not os.path.exists(self.storage_path): return {"records": []} try: with open(self.storage_path, "r", encoding="utf-8") as f: return json.load(f) except json.JSONDecodeError as e: logger.warning("歷史記錄 JSON 檔案損毀,降級回傳空結構:%s", e) return {"records": []} except (IOError, OSError) as e: logger.warning("無法讀取歷史記錄檔案,降級回傳空結構:%s", e) return {"records": []} def _save_raw(self, data: Dict[str, Any]) -> None: """將資料寫入 JSON 檔案。""" with open(self.storage_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) @staticmethod def _generate_id(result: BenchmarkResult) -> str: """依 timestamp 產生唯一識別碼。 格式:``benchmark_YYYYMMDD_HHMMSSffffff`` """ dt = datetime.fromtimestamp(result.timestamp) return dt.strftime("benchmark_%Y%m%d_%H%M%S%f") @staticmethod def _result_to_dict(result: BenchmarkResult) -> Dict[str, Any]: """將 BenchmarkResult 轉換為可序列化的字典。""" return { "id": result.id, "mode": result.mode, "fps": result.fps, "avg_latency_ms": result.avg_latency_ms, "p95_latency_ms": result.p95_latency_ms, "total_frames": result.total_frames, "timestamp": result.timestamp, "device_config": result.device_config, } @staticmethod def _dict_to_result(data: Dict[str, Any]) -> BenchmarkResult: """將字典轉換回 BenchmarkResult。""" return BenchmarkResult( id=data.get("id"), mode=data["mode"], fps=data["fps"], avg_latency_ms=data["avg_latency_ms"], p95_latency_ms=data["p95_latency_ms"], total_frames=data["total_frames"], timestamp=data["timestamp"], device_config=data.get("device_config", {}), )